From 29a15e4a3bdf8e91c900ad7c776f83ad82c684f6 Mon Sep 17 00:00:00 2001 From: Gare Date: Sat, 8 Aug 2026 16:24:07 +0200 Subject: [PATCH 01/50] Add a map provider abstraction and an AMap implementation Squashed from SadGare/OpenTagViewer (issue #44), commits ecbc444..43735a2. The original series carried a lot of iteration ("generate sha", "add sha", "rectify the workflow") that is not worth preserving individually. Rather than bolting a second SDK onto MapsActivity, this introduces IMapProvider with GoogleMapProvider and AMapProvider behind it, plus provider-neutral MapMarker and MapPolyline types and a MapProviderFactory. MapsActivity and HistoryViewActivity are reworked onto the interface, so a future provider is a new implementation rather than another rewrite. This is what issue #16 actually asked for. CoordinateConverter handles the GCJ-02 ("Mars coordinates") offset that mainland China mandates; without it pins land hundreds of metres out. Note for review: com.amap.api:3dmap is a new third-party dependency with its own telemetry posture, and it brings a second API key (AMAP_API_KEY) into the build. AMap keys are bound to a signing fingerprint, which is what the signing-report workflow in this series is for. Closes #16 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-debug.yml | 1 + .github/workflows/build-release.yml | 1 + app/build.gradle.kts | 17 +- app/src/main/AndroidManifest.xml | 15 + .../opentagviewer/HistoryViewActivity.java | 216 ++++--- .../android/opentagviewer/MapsActivity.java | 241 ++++--- .../opentagviewer/OpenAirTagApplication.java | 68 ++ .../opentagviewer/SettingsActivity.java | 49 ++ .../db/datastore/UserSettingsDataStore.java | 1 + .../db/repo/UserSettingsRepository.java | 4 + .../db/repo/model/UserSettings.java | 8 + .../opentagviewer/ui/maps/AMapProvider.java | 608 ++++++++++++++++++ .../ui/maps/CoordinateConverter.java | 89 +++ .../ui/maps/GoogleMapProvider.java | 360 +++++++++++ .../opentagviewer/ui/maps/IMapProvider.java | 206 ++++++ .../opentagviewer/ui/maps/MapMarker.java | 200 ++++++ .../opentagviewer/ui/maps/MapPolyline.java | 200 ++++++ .../ui/maps/MapProviderFactory.java | 34 + .../ui/maps/VectorImageGeneratorUtil.java | 29 +- .../main/res/layout/activity_history_view.xml | 15 +- app/src/main/res/layout/activity_maps.xml | 8 +- app/src/main/res/layout/activity_settings.xml | 11 + app/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 2 + local.defaults.properties | 3 +- 25 files changed, 2188 insertions(+), 201 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 441fd1b9..c3a00ff7 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -65,6 +65,7 @@ jobs: - name: Inject secrets.properties run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + echo "AMAP_API_KEY=${{ secrets.AMAP_API_KEY || 'amap_key_default_value' }}" >> secrets.properties # Run Tests Build - name: Run gradle tests diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index d6b1f8a3..7bf01e47 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -75,6 +75,7 @@ jobs: - name: Inject secrets.properties run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}" > secrets.properties + echo "AMAP_API_KEY=${{ secrets.AMAP_API_KEY }}" >> secrets.properties - name: Recreate release keystore run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd5f58b7..ff15a011 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,16 @@ plugins { secrets { // To add your Maps API key to this project: // 1. If the secrets.properties file does not exist, create it in the same folder as the local.properties file. - // 2. Add this line, where YOUR_API_KEY is your API key: + // 2. Add these lines, where YOUR_API_KEY is your API key: // MAPS_API_KEY=YOUR_API_KEY + // AMAP_API_KEY=YOUR_AMAP_API_KEY + // + // How to get Google Maps API Key: + // - Visit: https://console.cloud.google.com/google/maps-apis/ + // + // How to get AMap API Key (高德地图 API Key): + // - Visit: https://console.amap.com/dev/key/app + // - Guide: https://lbs.amap.com/api/android-sdk/guide/create-project/get-key propertiesFileName = "secrets.properties" // A properties file containing default secret values. This file can be @@ -128,6 +136,11 @@ dependencies { implementation(libs.androidx.emoji.views.helper) implementation(libs.androidx.emoji.picker) + // 高德地图SDK - Android 3D地图 V9.8.3 + // 参考文档:https://lbs.amap.com/api/android-sdk/gettingstarted + // 注意:3D地图SDK已包含定位功能,无需单独引入location SDK + implementation(libs.amap.map3d) + testImplementation(libs.junit) testImplementation(libs.android.room.testing) testCompileOnly(libs.projectlombok) @@ -141,4 +154,4 @@ dependencies { testAnnotationProcessor(libs.projectlombok) coreLibraryDesugaring(libs.desugar.jdk.libs) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 610e3d87..f2ac8ca0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,13 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + + + + + + + @@ -81,11 +88,19 @@ 1. Open the root project's secrets.properties file 2. Add this line, where YOUR_API_KEY is your API key: MAPS_API_KEY=YOUR_API_KEY + AMAP_API_KEY=YOUR_AMAP_API_KEY --> + + + + + reports) { this.historyItemsAdapter.notifyItemRangeInserted(0, this.locations.size()); // draw datapoints on map - var coords = this.locations.stream().map(rep -> new LatLng(rep.getLatitude(), rep.getLongitude())) + var coords = this.locations.stream() + .map(rep -> new MapPolyline.LatLng(rep.getLatitude(), rep.getLongitude())) .collect(Collectors.toList()); this.drawNewLines(coords); if (!coords.isEmpty()) { if (coords.size() == 1) { - this.animateCameraToPos(coords.get(0), SINGLE_MARKER_ZOOM, null); + MapPolyline.LatLng coord = coords.get(0); + this.animateCameraToPos(coord.getLatitude(), coord.getLongitude(), SINGLE_MARKER_ZOOM, null); } else { var bounds = this.determineBoundsForCurrent(); this.animateCameraToBoundingBox(bounds, FOCUS_PADDING); @@ -617,28 +662,44 @@ private LatLngBounds determineBoundsForCurrent() { ); } - private void drawNewLines(final List coords) { + private void drawNewLines(final List coords) { + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet, cannot draw lines"); + return; + } + final int numCoords = coords.size(); if (numCoords > 1) { - var optionsOutlineLine = new PolylineOptions() + // 清除旧的路径线 + this.cleanupOldLines(); + + // 添加轮廓线 + MapPolyline outlineLine = MapPolyline.builder() + .points(coords) + .id("history_line_outline") .color(this.getColor(R.color.maps_line_outline)) .width(OUTLINE_WIDTH) - .clickable(false) - .addAll(coords); - this.currentHistoryLineOutline = this.map.addPolyline(optionsOutlineLine); + .build(); + this.currentHistoryLineOutlineId = this.mapProvider.addPolyline(outlineLine); - var optionsPrimaryLine = new PolylineOptions() + // 添加主路径线 + MapPolyline primaryLine = MapPolyline.builder() + .points(coords) + .id("history_line_primary") .color(this.getColor(R.color.maps_line_primary)) .width(LINE_WIDTH) - .clickable(true) - .addAll(coords); - - this.currentHistoryLine = this.map.addPolyline(optionsPrimaryLine); + .build(); + this.currentHistoryLineId = this.mapProvider.addPolyline(primaryLine); } else if (numCoords == 1) { // if we just had a single item, then draw a single marker - var markerOptions = new MarkerOptions() - .position(coords.get(0)); - this.singleCoordMarker = this.map.addMarker(markerOptions); + MapPolyline.LatLng coord = coords.get(0); + MapMarker marker = MapMarker.builder() + .id("single_coord_marker") + .latitude(coord.getLatitude()) + .longitude(coord.getLongitude()) + .useDefaultIcon(true) + .build(); + this.singleCoordMarkerId = this.mapProvider.addMarker(marker); } } @@ -662,47 +723,58 @@ private void handleOnClickHistoryListItem(final HistoryItemsAdapter.ClickedItemI } // remove current - if (this.singleCoordMarker != null) { - this.singleCoordMarker.remove(); + if (this.singleCoordMarkerId != null && this.mapProvider != null) { + this.mapProvider.removeMarker(this.singleCoordMarkerId); } final boolean sheetIsFullyExpanded = bottomSheetBehavior.getState() == STATE_EXPANDED; // replace with new - var pos = new LatLng(clickedReport.getLatitude(), clickedReport.getLongitude()); - var markerOptions = new MarkerOptions().position(pos).title(locationName); - - this.singleCoordMarker = this.map.addMarker(markerOptions); - - if (sheetIsFullyExpanded) { - this.overrideMapPadding = true; - this.map.setPadding(0, 0, 0, bottomSheetBehavior.getPeekHeight()); - this.animateCameraToPos(pos, SINGLE_MARKER_ZOOM, () -> { - Log.d(TAG, "Undoing overrideMapPadding override!"); - this.overrideMapPadding = false; - }); - bottomSheetBehavior.setState(STATE_HALF_EXPANDED); - } else { - this.animateCameraToPos(pos, SINGLE_MARKER_ZOOM, null); + if (this.mapProvider != null) { + MapMarker marker = MapMarker.builder() + .id("single_coord_marker") + .latitude(clickedReport.getLatitude()) + .longitude(clickedReport.getLongitude()) + .title(locationName) + .useDefaultIcon(true) + .build(); + this.singleCoordMarkerId = this.mapProvider.addMarker(marker); + + if (sheetIsFullyExpanded) { + this.overrideMapPadding = true; + this.mapProvider.setPadding(0, 0, 0, bottomSheetBehavior.getPeekHeight()); + this.animateCameraToPos(clickedReport.getLatitude(), clickedReport.getLongitude(), SINGLE_MARKER_ZOOM, () -> { + Log.d(TAG, "Undoing overrideMapPadding override!"); + this.overrideMapPadding = false; + }); + bottomSheetBehavior.setState(STATE_HALF_EXPANDED); + } else { + this.animateCameraToPos(clickedReport.getLatitude(), clickedReport.getLongitude(), SINGLE_MARKER_ZOOM, null); + } } } private void animateCameraToBoundingBox(final LatLngBounds bounds, int padding) { - this.map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); + if (this.mapProvider == null || bounds == null) return; + + // 将LatLngBounds转换为点列表 + List points = new ArrayList<>(); + points.add(new MapPolyline.LatLng(bounds.southwest.latitude, bounds.southwest.longitude)); + points.add(new MapPolyline.LatLng(bounds.northeast.latitude, bounds.northeast.longitude)); + + // 使用GoogleMapProvider的特殊方法(如果可用) + if (this.mapProvider instanceof GoogleMapProvider) { + ((GoogleMapProvider) this.mapProvider).animateCameraToBounds(points, padding); + } else { + // 对于其他提供商,计算中心点和缩放级别 + double centerLat = (bounds.southwest.latitude + bounds.northeast.latitude) / 2.0; + double centerLon = (bounds.southwest.longitude + bounds.northeast.longitude) / 2.0; + this.mapProvider.animateCamera(centerLat, centerLon, this.defaultZoom, null); + } } - - private void animateCameraToPos(final LatLng pos, float zoom, Runnable onFinished) { - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom(pos, zoom), new GoogleMap.CancelableCallback() { - @Override - public void onCancel() { - if (onFinished != null) onFinished.run(); - } - - @Override - public void onFinish() { - if (onFinished != null) onFinished.run(); - } - }); + private void animateCameraToPos(double latitude, double longitude, float zoom, Runnable onFinished) { + if (this.mapProvider == null) return; + this.mapProvider.animateCamera(latitude, longitude, zoom, onFinished); } } \ No newline at end of file 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 b3becf0f..e7e5179e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -46,12 +46,18 @@ import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; -import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.gms.maps.GoogleMap.OnMapClickListener; + +import dev.wander.android.opentagviewer.ui.maps.IMapProvider; +import dev.wander.android.opentagviewer.ui.maps.MapProviderFactory; +import dev.wander.android.opentagviewer.ui.maps.GoogleMapProvider; +import dev.wander.android.opentagviewer.ui.maps.AMapProvider; +import dev.wander.android.opentagviewer.ui.maps.MapMarker; +import dev.wander.android.opentagviewer.ui.maps.MapPolyline; import com.google.android.libraries.places.api.Places; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -106,7 +112,7 @@ /** * TODO: this whole thing is a bit of a godclass. Decouple it. */ -public class MapsActivity extends AppCompatActivity implements OnMapReadyCallback, OnMapClickListener, GoogleMap.OnMarkerClickListener, ActivityCompat.OnRequestPermissionsResultCallback { +public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMapReadyCallback, IMapProvider.OnMapClickListener, IMapProvider.OnMarkerClickListener, ActivityCompat.OnRequestPermissionsResultCallback { private static final String TAG = MapsActivity.class.getSimpleName(); private static final int LOCATION_PERMISSION_REQUEST_CODE = 1; @@ -125,7 +131,8 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac private static final float MARKER_ZINDEX_TOP = 10.0f; - private GoogleMap map; + private IMapProvider mapProvider; + private GoogleMap map; // 保留用于向后兼容,逐步迁移 private ActivityMapsBinding binding; @@ -149,9 +156,8 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac private final Map> beaconLocations = new ConcurrentHashMap<>(); - private final Map currentMarkers = new ConcurrentHashMap<>(); - - private Marker lastFocusedMarker = null; + private final Map currentMarkers = new ConcurrentHashMap<>(); // 存储markerId + private Marker lastFocusedMarker = null; // 保留用于向后兼容(仅Google Maps) private final Map dynamicCardsForTag = new ConcurrentHashMap<>(); @@ -268,10 +274,15 @@ protected void onCreate(Bundle savedInstanceState) { .subscribe(pos -> { Log.d(TAG, "Got previous camera position to reset us to: " + pos); - pos.ifPresent(userMapCameraPosition -> this.map.moveCamera(CameraUpdateFactory.newLatLngZoom( - new LatLng(userMapCameraPosition.getLat(), userMapCameraPosition.getLon()), - userMapCameraPosition.getZoom() - ))); + pos.ifPresent(userMapCameraPosition -> { + if (this.mapProvider != null) { + this.mapProvider.moveCamera( + userMapCameraPosition.getLat(), + userMapCameraPosition.getLon(), + userMapCameraPosition.getZoom() + ); + } + }); }, error -> Log.e(TAG, "Failed to get last camera position!", error)); @@ -279,10 +290,12 @@ protected void onCreate(Bundle savedInstanceState) { this.windowWidth = this.getResources().getDisplayMetrics().widthPixels; - // Obtain the SupportMapFragment and get notified when the map is ready to be used. - SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager() - .findFragmentById(R.id.map); - mapFragment.getMapAsync(this); + // 根据用户设置创建地图提供商 + String mapProviderType = this.userSettings.getMapProvider(); + IMapProvider tempProvider = MapProviderFactory.create(mapProviderType); + + // 初始化地图(注意:this.mapProvider 仅在 onMapReady 后赋值,避免初始化竞态) + tempProvider.initialize(this, R.id.map, this); } @@ -290,59 +303,71 @@ protected void onCreate(Bundle savedInstanceState) { /** * Manipulates the map once available. * This callback is triggered when the map is ready to be used. - * This is where we can add markers or lines, add listeners or move the camera. In this case, - * we just add a marker near Sydney, Australia. - * If Google Play services is not installed on the device, the user will be prompted to install - * it inside the SupportMapFragment. This method will only be triggered once the user has - * installed Google Play services and returned to the app. */ @Override - public void onMapReady(GoogleMap googleMap) { - map = googleMap; + public void onMapReady(IMapProvider provider) { + this.mapProvider = provider; - map.setOnMapClickListener(this); - map.setOnMarkerClickListener(this); + // 如果是Google Maps,保留向后兼容 + if (provider instanceof GoogleMapProvider) { + this.map = ((GoogleMapProvider) provider).getGoogleMap(); + } - map.setPadding(0, 0, 0, GOOGLE_LOGO_PADDING_BOTTOM_PX); + mapProvider.setOnMapClickListener(this); + mapProvider.setOnMarkerClickListener(this); + + mapProvider.setPadding(0, 0, 0, GOOGLE_LOGO_PADDING_BOTTOM_PX); // We don't want to use the default button. We have a custom button - map.getUiSettings().setMyLocationButtonEnabled(false); - map.getUiSettings().setRotateGesturesEnabled(false); // no rotation (mostly bc very annoying to reset) - map.getUiSettings().setCompassEnabled(false); // not needed due to no rotation being allowed - map.getUiSettings().setMapToolbarEnabled(false); // we have a custom button for this + mapProvider.setMyLocationButtonEnabled(false); + mapProvider.setRotateGesturesEnabled(false); // no rotation (mostly bc very annoying to reset) + mapProvider.setCompassEnabled(false); // not needed due to no rotation being allowed + mapProvider.setMapToolbarEnabled(false); // we have a custom button for this if (this.userSettings.hasDarkThemeEnabled()) { // DARK THEME map - map.setMapStyle(MapStyleOptions.loadRawResourceStyle(this.getApplicationContext(), R.raw.map_dark_style)); + mapProvider.setMapStyle(true); + } else { + mapProvider.setMapStyle(false); } this.enableMyLocation(false); + + // 地图准备好后,刷新一次当前显示的信标位置 + this.showLastDeviceLocations(); } @Override protected void onPause() { super.onPause(); - if (this.map != null) { - var pos = this.map.getCameraPosition(); - var async = this.userDataRepository.storeLastCameraPosition( - UserMapCameraPosition.builder() - .zoom(pos.zoom) - .lat(pos.target.latitude) - .lon(pos.target.longitude) - .build() - ).subscribe( - success -> Log.d(TAG, "Success storing last camera position!"), - error -> Log.e(TAG, "Error storing last camera position!", error)); + if (this.mapProvider != null) { + IMapProvider.CameraPosition pos = this.mapProvider.getCameraPosition(); + if (pos != null) { + var async = this.userDataRepository.storeLastCameraPosition( + UserMapCameraPosition.builder() + .zoom(pos.getZoom()) + .lat(pos.getLatitude()) + .lon(pos.getLongitude()) + .build() + ).subscribe( + success -> Log.d(TAG, "Success storing last camera position!"), + error -> Log.e(TAG, "Error storing last camera position!", error)); + } // cleanup location refresh task refreshSchedulerHandler.removeCallbacks(this.nextLocationRefreshTask); this.nextLocationRefreshTask = null; } + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onPause(); + } } @Override - public void onMapClick(LatLng point) { - Log.i(TAG, "tapped, point=" + point); + public void onMapClick(double latitude, double longitude) { + Log.i(TAG, "tapped, point=(" + latitude + ", " + longitude + ")"); // TODO: hide UI elements when this occurs! } @@ -354,6 +379,21 @@ protected void onResume() { this.refreshIfAllowed(); this.reSchedulePeriodicTagLocationRefresher(); + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onResume(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onDestroy(); + } } private void reSchedulePeriodicTagLocationRefresher() { @@ -450,9 +490,14 @@ private void animateCameraToMyLocation() { } Log.d(TAG, "Navigating to current user position on the map..."); - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom( - new LatLng(location.getLatitude(), location.getLongitude()), - CAMERA_ON_MAP_INITIAL_ZOOM)); + if (this.mapProvider != null) { + this.mapProvider.animateCamera( + location.getLatitude(), + location.getLongitude(), + CAMERA_ON_MAP_INITIAL_ZOOM, + null + ); + } }); } else { @@ -660,14 +705,16 @@ public void onClickLocationHistory(View view) { .findFirst() .orElseThrow(() -> new RuntimeException("Click location history event was raised by a Beacon Device's card, but the beaconId could not be found for it!")); - var pos = this.map.getCameraPosition(); + IMapProvider.CameraPosition pos = this.mapProvider != null ? this.mapProvider.getCameraPosition() : null; Log.d(TAG, "Going to the history page for beaconId=" + beaconId); Intent viewHistoryIntent = new Intent(this, HistoryViewActivity.class); viewHistoryIntent.putExtra("beaconId", beaconId); - viewHistoryIntent.putExtra("lon", pos.target.longitude); - viewHistoryIntent.putExtra("lat", pos.target.latitude); - viewHistoryIntent.putExtra("zoom", pos.zoom); + if (pos != null) { + viewHistoryIntent.putExtra("lon", pos.getLongitude()); + viewHistoryIntent.putExtra("lat", pos.getLatitude()); + viewHistoryIntent.putExtra("zoom", pos.getZoom()); + } startActivity(viewHistoryIntent); } @@ -933,25 +980,28 @@ private synchronized void showLastDeviceLocations() { private synchronized void showBeaconOnMap(final BeaconInformation beacon, final BeaconLocationReport lastLocation) { final String beaconId = beacon.getBeaconId(); - final LatLng locationTag = new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet, cannot show beacon"); + return; + } if (this.currentMarkers.containsKey(beaconId)) { - // remove the old marker - Log.d(TAG, "Going to move the existing marker for beaconId=" + beaconId); - var marker = Objects.requireNonNull(this.currentMarkers.get(beaconId)); - marker.setPosition(locationTag); - return; + // remove the old marker and add a new one + Log.d(TAG, "Going to replace the existing marker for beaconId=" + beaconId); + this.mapProvider.removeMarker(beaconId); } Log.d(TAG, "Going to add new marker for beaconId=" + beaconId); - BitmapDescriptor icon; + // 创建自定义标记图标 + android.graphics.Bitmap iconBitmap; if (beacon.isEmojiFilled()) { - icon = VectorImageGeneratorUtil.makeMarker( + iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), beacon.getEmoji(), getColor(R.color.md_theme_background)); } else { - icon = VectorImageGeneratorUtil.makeMarker( + iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), R.drawable.apple, getColor(R.color.md_theme_background), @@ -959,18 +1009,19 @@ private synchronized void showBeaconOnMap(final BeaconInformation beacon, final ); } - var markerOptions = new MarkerOptions() - .position(locationTag) - //.title(markerTitle) - .icon(icon); - Marker marker = this.map.addMarker(markerOptions); + // 使用抽象接口添加标记 + MapMarker marker = MapMarker.builder() + .id(beaconId) + .latitude(lastLocation.getLatitude()) + .longitude(lastLocation.getLongitude()) + .icon(iconBitmap) + .build(); + + String markerId = this.mapProvider.addMarker(marker); + this.currentMarkers.put(beaconId, markerId); // 存储markerId而不是Marker对象 - this.currentMarkers.put(beaconId, marker); if (this.currentMarkers.size() == 1) { // for the first marker, navigate to it smoothly on the map! - // (we choose the first added marker here because it is the - // one that will become visible in the UI tag list at the - // bottom of the screen) this.goToBeaconOnMap(beaconId, CAMERA_ON_MAP_INITIAL_ZOOM); } } @@ -991,36 +1042,37 @@ private void goToBeaconOnMap(final String beaconId) { private void goToBeaconOnMap(final String beaconId, Float zoom) { try { - Marker marker = Objects.requireNonNull(this.currentMarkers.get(beaconId)); - this.bringMarkerToTop(marker); - - var pos = marker.getPosition(); + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet"); + return; + } + + // 从beaconLocations获取位置信息 + List locations = this.beaconLocations.get(beaconId); + if (locations == null || locations.isEmpty()) { + Log.w(TAG, "No locations found for beaconId=" + beaconId); + return; + } + + BeaconLocationReport lastLocation = locations.get(locations.size() - 1); + double lat = lastLocation.getLatitude(); + double lon = lastLocation.getLongitude(); Log.d(TAG, "Animating camera to position of marker for beaconId=" + beaconId + " after it was selected in the bottom tag list..."); if (zoom != null) { - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom(pos, zoom)); + this.mapProvider.animateCamera(lat, lon, zoom, null); } else { - this.map.animateCamera(CameraUpdateFactory.newLatLng(pos)); + // 使用当前缩放级别 + IMapProvider.CameraPosition currentPos = this.mapProvider.getCameraPosition(); + float currentZoom = currentPos != null ? currentPos.getZoom() : CAMERA_ON_MAP_INITIAL_ZOOM; + this.mapProvider.animateCamera(lat, lon, currentZoom, null); } } catch (Exception e) { Log.e(TAG, "Failure when trying to navigate to marker on map on lock into card for beaconId=" + beaconId, e); } } - private void bringMarkerToTop(Marker marker) { - if (this.lastFocusedMarker == marker) { - // do nothing - return; - } - - if (this.lastFocusedMarker != null) { - this.lastFocusedMarker.setZIndex(MARKER_ZINDEX_DEFAULT); - } - marker.setZIndex(MARKER_ZINDEX_TOP); - this.lastFocusedMarker = marker; - } - private synchronized void updateBeaconCards() { HorizontalScrollView scrollContainer = this.findViewById(R.id.tags_scrollable_area); LinearLayout cardsContainer = this.findViewById(R.id.tags_scroll_container); @@ -1179,7 +1231,10 @@ private void enableMyLocation(boolean navigateToMyLocation) { if (ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) { Log.i(TAG, "Enabling 'my location' related UI features..."); - this.map.setMyLocationEnabled(true); + // 注意:抽象接口可能不支持setMyLocationEnabled,这里保留向后兼容 + if (this.map != null) { + this.map.setMyLocationEnabled(true); + } // This UI button is only available if the user enables own location permissions. ImageButton button = findViewById(R.id.button_my_location); @@ -1230,14 +1285,10 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis } @Override - public boolean onMarkerClick(@NonNull Marker marker) { - this.bringMarkerToTop(marker); - - Optional beaconIdForMarker = this.currentMarkers.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(marker)) - .map(Map.Entry::getKey) - .findFirst(); - + public boolean onMarkerClick(String markerId) { + // 查找对应的beaconId + Optional beaconIdForMarker = Optional.ofNullable(markerId); + if (beaconIdForMarker.isPresent()) { this.tagListSwiperHelper.navigateToCard(beaconIdForMarker.get()); } else { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java index 9488a3ff..ba18bb44 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java @@ -17,9 +17,77 @@ public class OpenAirTagApplication extends PyApplication { public void onCreate() { super.onCreate(); + // 高德地图SDK隐私合规初始化 + // 必须在调用任何SDK接口之前调用 + this.initAMapPrivacyCompliance(); + this.setupTheme(); } + /** + * 高德地图隐私合规设置 + * 根据《个人信息保护法》要求,必须在调用SDK任何接口之前进行隐私合规配置 + * 参考文档:https://lbs.amap.com/api/android-sdk/guide/create-map/dev-attention + */ + private void initAMapPrivacyCompliance() { + try { + // FIX: 尝试读取并修复可能被误解析为Integer的AMap API Key + try { + android.content.pm.ApplicationInfo appInfo = this.getPackageManager().getApplicationInfo(this.getPackageName(), android.content.pm.PackageManager.GET_META_DATA); + if (appInfo.metaData != null) { + Object keyObj = appInfo.metaData.get("com.amap.api.v2.apikey"); + if (keyObj instanceof Integer) { + Log.w(TAG, "AMap API Key was parsed as Integer (" + keyObj + "), converting to String..."); + String keyStr; + try { + // 尝试作为资源ID读取 + keyStr = this.getString((Integer) keyObj); + } catch (Exception e) { + // 否则直接转换为字符串 + keyStr = String.valueOf(keyObj); + } + + Log.i(TAG, "Recovered AMap Key: " + keyStr); + + // 尝试通过反射设置Key + // com.amap.api.maps.MapsInitializer.setApiKey(String) + Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); + java.lang.reflect.Method setApiKeyMethod = mapsInitializerClass.getMethod("setApiKey", String.class); + setApiKeyMethod.invoke(null, keyStr); + Log.i(TAG, "Successfully set AMap API Key manually via reflection"); + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to fix AMap API Key", e); + } + + // 使用反射加载高德地图SDK,避免编译时依赖 + Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); + + // 更新隐私合规弹窗状态 + // updatePrivacyShow(Context context, boolean isContains, boolean isShow) + // isContains: 隐私权政策是否包含高德开平隐私权政策 + // isShow: 隐私权政策是否弹窗展示告知用户 + java.lang.reflect.Method updatePrivacyShowMethod = mapsInitializerClass.getMethod( + "updatePrivacyShow", android.content.Context.class, boolean.class, boolean.class); + updatePrivacyShowMethod.invoke(null, this, true, true); + + // 更新用户同意隐私政策状态 + // updatePrivacyAgree(Context context, boolean isAgree) + // isAgree: 隐私权政策是否取得用户同意 + java.lang.reflect.Method updatePrivacyAgreeMethod = mapsInitializerClass.getMethod( + "updatePrivacyAgree", android.content.Context.class, boolean.class); + updatePrivacyAgreeMethod.invoke(null, this, true); + + Log.i(TAG, "AMap privacy compliance initialized successfully"); + } catch (ClassNotFoundException e) { + // 高德地图SDK未集成,这是正常情况 + Log.d(TAG, "AMap SDK not found, privacy compliance initialization skipped"); + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AMap privacy compliance", e); + } + } + public void setupTheme() { final int currentNightMode = this.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 4c98950d..43631555 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -132,6 +132,8 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setCurrentLanguage(Optional.ofNullable(this.currentSettings.getLanguage()).map(this::getPrettyLanguageName).orElse(this.getString(R.string.use_system_default))); this.binding.setOnClickAnisetteServerUrl(this::onClickEditAnisetteServerUrl); this.binding.setCurrentAnisetteServerUrl(this.currentSettings.getAnisetteServerUrl()); + this.binding.setOnClickMapProvider(this::onClickEditMapProvider); + this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); this.binding.setIsDebugDataEnabled(Optional.ofNullable(this.currentSettings.getEnableDebugData()).orElse(false)); if (this.getSupportActionBar() != null) { @@ -203,6 +205,53 @@ private void onClickEditTheme() { builder.show(); } + private String getCurrentMapProviderUiString() { + String provider = this.currentSettings.getMapProvider(); + if (provider == null || provider.isEmpty() || "google".equals(provider)) { + return this.getString(R.string.map_provider_google); + } else if ("amap".equals(provider)) { + return this.getString(R.string.map_provider_amap); + } + return this.getString(R.string.map_provider_google); + } + + private void onClickEditMapProvider() { + List providerChoices = new ArrayList<>(); + providerChoices.add(this.getString(R.string.map_provider_google)); + providerChoices.add(this.getString(R.string.map_provider_amap)); + + String currentProvider = this.currentSettings.getMapProvider(); + int currentOption = 0; // 默认Google Maps + if ("amap".equals(currentProvider)) { + currentOption = 1; + } + + var builder = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.map_provider) + .setPositiveButton(R.string.accept, (dialog, which) -> { + Log.d(TAG, "Selected new map provider option!"); + + int checkedItemPosition = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + + if (checkedItemPosition != AdapterView.INVALID_POSITION) { + String selectedProvider = checkedItemPosition == 0 ? "google" : "amap"; + Log.d(TAG, "Selected map provider: " + selectedProvider); + this.updateMapProvider(selectedProvider); + } + }) + .setNegativeButton(R.string.cancel, null) + .setSingleChoiceItems(providerChoices.toArray(new CharSequence[0]), currentOption, null); + + builder.show(); + } + + private void updateMapProvider(String provider) { + this.currentSettings.setMapProvider(provider); + this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); + this.saveSettings(); + Log.i(TAG, "Updated map provider to: " + provider); + } + private void onClickEditLanguage() { View view = inflate(this, R.layout.language_input_dialog, null); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index 258859a1..42dc5e6a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -21,6 +21,7 @@ public final class UserSettingsDataStore { public static final Preferences.Key USE_DARK_THEME = PreferencesKeys.booleanKey("use_dark_theme"); public static final Preferences.Key ENABLE_DEBUG_DATA = PreferencesKeys.booleanKey("enable_debug_data"); + public static final Preferences.Key MAP_PROVIDER = PreferencesKeys.stringKey("map_provider"); public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index 7c7429a9..fea2ca56 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -3,6 +3,7 @@ import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ANISETTE_SERVER_URL; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ENABLE_DEBUG_DATA; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.MAP_PROVIDER; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_DARK_THEME; import androidx.datastore.preferences.core.MutablePreferences; @@ -28,12 +29,14 @@ public UserSettings getUserSettings() { String language = settings.get(LANGUAGE); Boolean useDarkTheme = settings.get(USE_DARK_THEME); Boolean enableDebugData = settings.get(ENABLE_DEBUG_DATA); + String mapProvider = settings.get(MAP_PROVIDER); return UserSettings.builder() .anisetteServerUrl(anisetteServerUrl) .language(language) .useDarkTheme(useDarkTheme) .enableDebugData(enableDebugData) + .mapProvider(mapProvider) .build(); }).subscribeOn(Schedulers.io()) @@ -49,6 +52,7 @@ public Completable storeUserSettings(UserSettings userSettings) { mutablePreferences.set(LANGUAGE, userSettings.getLanguage()); mutablePreferences.set(USE_DARK_THEME, userSettings.getUseDarkTheme()); mutablePreferences.set(ENABLE_DEBUG_DATA, userSettings.getEnableDebugData()); + mutablePreferences.set(MAP_PROVIDER, userSettings.getMapProvider()); return Single.just(mutablePreferences); }).subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index 7ddc3ec1..ef9ad2b7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -10,8 +10,16 @@ public class UserSettings { private String anisetteServerUrl; private String language; private Boolean enableDebugData; + private String mapProvider; // "google" or "amap" public boolean hasDarkThemeEnabled() { return this.useDarkTheme == Boolean.TRUE; } + + /** + * 获取地图提供商,默认为"google" + */ + public String getMapProvider() { + return mapProvider != null && !mapProvider.isEmpty() ? mapProvider : "google"; + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java new file mode 100644 index 00000000..19c0d580 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java @@ -0,0 +1,608 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.util.Log; +import android.view.View; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 高德地图提供商实现 + * + * 高德地图Android SDK V9.8.3 (3D地图) + * 发布日期:2023-12-06 + * + * SDK依赖配置: + * dependencies { + * implementation 'com.amap.api:3dmap:9.8.3' + * // 注意:3D地图SDK已包含定位功能,无需单独引入location SDK + * } + * + * API Key配置: + * 在AndroidManifest.xml中添加: + * + * + * 隐私合规: + * 必须在Application的onCreate中调用隐私合规接口: + * MapsInitializer.updatePrivacyShow(context, true, true); + * MapsInitializer.updatePrivacyAgree(context, true); + * + * 参考文档: + * - 隐私合规:https://lbs.amap.com/api/android-sdk/guide/create-map/dev-attention + * - SDK初始化:https://lbs.amap.com/api/android-sdk/guide/create-project/android-studio-create-project + * - 入门指南:https://lbs.amap.com/api/android-sdk/gettingstarted + */ +public class AMapProvider implements IMapProvider { + private static final String TAG = AMapProvider.class.getSimpleName(); + + private Activity activity; + private OnMapReadyCallback callback; + + // 存储标记点和路径线 + private final Map markers = new HashMap<>(); // 存储AMap.Marker对象 + private final Map polylines = new HashMap<>(); // 存储Polyline对象 + + // 监听器 + private OnMapClickListener onMapClickListener; + private OnMarkerClickListener onMarkerClickListener; + + // 高德地图对象 + // 使用3D地图SDK:com.amap.api.maps.AMap + private Object aMap; // com.amap.api.maps.AMap + private View mapView; // com.amap.api.maps.TextureMapView + + private boolean isDarkMode = false; + + @Override + public void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback) { + this.activity = activity; + this.callback = callback; + + try { + // 使用反射加载高德地图3D SDK,避免编译时依赖 + // 高德地图3D地图使用 TextureMapView 或 MapView + // TextureMapView 性能更好,推荐使用 + Class mapViewClass = Class.forName("com.amap.api.maps.TextureMapView"); + Object mapView = mapViewClass.getConstructor(android.content.Context.class) + .newInstance(activity); + + this.mapView = (View) mapView; + + // 获取AMap对象 + // com.amap.api.maps.AMap + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method getMapMethod = mapViewClass.getMethod("getMap"); + this.aMap = getMapMethod.invoke(mapView); + + // 调用 onCreate 生命周期方法(高德地图必需) + // TextureMapView.onCreate(Bundle savedInstanceState) + java.lang.reflect.Method onCreateMethod = mapViewClass.getMethod("onCreate", android.os.Bundle.class); + onCreateMethod.invoke(mapView, (android.os.Bundle) null); + Log.d(TAG, "AMap TextureMapView onCreate() called"); + + // 添加到容器,并设置 LayoutParams 确保 View 填充整个容器 + android.view.ViewGroup container = activity.findViewById(containerViewId); + if (container != null) { + android.view.ViewGroup.LayoutParams layoutParams = new android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ); + container.addView((View) mapView, layoutParams); + Log.d(TAG, "AMap view added to container with MATCH_PARENT layout params"); + } else { + Log.e(TAG, "Container view not found for id: " + containerViewId); + } + + // 设置地图准备就绪回调 + if (this.callback != null) { + this.callback.onMapReady(this); + Log.d(TAG, "AMap onMapReady callback triggered"); + } + + Log.i(TAG, "AMap initialization completed successfully"); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AMap", e); + throw new RuntimeException("Failed to initialize AMap SDK. Please ensure AMap SDK is properly configured.", e); + } + } + + @Override + public void setMapStyle(boolean darkMode) { + this.isDarkMode = darkMode; + if (aMap == null) return; + + try { + // 高德地图设置地图类型和样式 + // com.amap.api.maps.AMap.MAP_TYPE_NORMAL = 1 (白天模式) + // com.amap.api.maps.AMap.MAP_TYPE_NIGHT = 4 (夜间模式) + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method setMapTypeMethod = aMapClass.getMethod("setMapType", int.class); + + // 设置地图类型:夜间模式或普通模式 + int mapType = darkMode ? 4 : 1; // MAP_TYPE_NIGHT : MAP_TYPE_NORMAL + setMapTypeMethod.invoke(aMap, mapType); + + Log.d(TAG, "Map style set to dark mode: " + darkMode); + } catch (Exception e) { + Log.e(TAG, "Failed to set map style", e); + } + } + + @Override + public String addMarker(MapMarker marker) { + if (aMap == null) { + Log.w(TAG, "AMap is not ready yet, cannot add marker"); + return marker.getId(); + } + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(marker.getLatitude(), marker.getLongitude()); + + // 创建MarkerOptions + // com.amap.api.maps.model.MarkerOptions + Class markerOptionsClass = Class.forName("com.amap.api.maps.model.MarkerOptions"); + Object markerOptions = markerOptionsClass.newInstance(); + + // 设置位置 + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + java.lang.reflect.Method positionMethod = markerOptionsClass.getMethod("position", latLngClass); + positionMethod.invoke(markerOptions, latLng); + + // 设置标题和片段 + if (marker.getTitle() != null) { + java.lang.reflect.Method titleMethod = markerOptionsClass.getMethod("title", String.class); + titleMethod.invoke(markerOptions, marker.getTitle()); + } + if (marker.getSnippet() != null) { + java.lang.reflect.Method snippetMethod = markerOptionsClass.getMethod("snippet", String.class); + snippetMethod.invoke(markerOptions, marker.getSnippet()); + } + + // 设置图标 + if (marker.getIconBitmap() != null) { + Class bitmapDescriptorClass = Class.forName("com.amap.api.maps.model.BitmapDescriptor"); + Class bitmapDescriptorFactoryClass = Class.forName("com.amap.api.maps.model.BitmapDescriptorFactory"); + java.lang.reflect.Method fromBitmapMethod = bitmapDescriptorFactoryClass.getMethod("fromBitmap", Bitmap.class); + Object bitmapDescriptor = fromBitmapMethod.invoke(null, marker.getIconBitmap()); + + java.lang.reflect.Method iconMethod = markerOptionsClass.getMethod("icon", bitmapDescriptorClass); + iconMethod.invoke(markerOptions, bitmapDescriptor); + } + + // 添加标记 + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method addMarkerMethod = aMapClass.getMethod("addMarker", markerOptionsClass); + Object amapMarker = addMarkerMethod.invoke(aMap, markerOptions); + + markers.put(marker.getId(), amapMarker); + + return marker.getId(); + } catch (Exception e) { + Log.e(TAG, "Failed to add marker", e); + return marker.getId(); + } + } + + @Override + public void removeMarker(String markerId) { + Object marker = markers.remove(markerId); + if (marker != null) { + try { + java.lang.reflect.Method removeMethod = marker.getClass().getMethod("remove"); + removeMethod.invoke(marker); + } catch (Exception e) { + Log.e(TAG, "Failed to remove marker", e); + } + } + } + + @Override + public void clearMarkers() { + for (Object marker : markers.values()) { + try { + java.lang.reflect.Method removeMethod = marker.getClass().getMethod("remove"); + removeMethod.invoke(marker); + } catch (Exception e) { + Log.e(TAG, "Failed to remove marker", e); + } + } + markers.clear(); + } + + @Override + public String addPolyline(MapPolyline polyline) { + if (aMap == null) { + Log.w(TAG, "AMap is not ready yet, cannot add polyline"); + return polyline.getId(); + } + + try { + // 坐标转换:WGS84 -> GCJ02 + List gcj02Points = polyline.getPoints().stream() + .map(p -> { + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(p.getLatitude(), p.getLongitude()); + return new MapPolyline.LatLng(gcj02[0], gcj02[1]); + }) + .collect(Collectors.toList()); + + // 创建PolylineOptions + // com.amap.api.maps.model.PolylineOptions + Class polylineOptionsClass = Class.forName("com.amap.api.maps.model.PolylineOptions"); + Object polylineOptions = polylineOptionsClass.newInstance(); + + // 添加点 + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + java.lang.reflect.Method addMethod = polylineOptionsClass.getMethod("add", latLngClass); + + for (MapPolyline.LatLng point : gcj02Points) { + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(point.getLatitude(), point.getLongitude()); + addMethod.invoke(polylineOptions, latLng); + } + + // 设置颜色和宽度 + java.lang.reflect.Method colorMethod = polylineOptionsClass.getMethod("color", int.class); + colorMethod.invoke(polylineOptions, polyline.getColor()); + + java.lang.reflect.Method widthMethod = polylineOptionsClass.getMethod("width", float.class); + widthMethod.invoke(polylineOptions, polyline.getWidth()); + + // 添加路径线 + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method addPolylineMethod = aMapClass.getMethod("addPolyline", polylineOptionsClass); + Object amapPolyline = addPolylineMethod.invoke(aMap, polylineOptions); + + polylines.put(polyline.getId(), amapPolyline); + + return polyline.getId(); + } catch (Exception e) { + Log.e(TAG, "Failed to add polyline", e); + return polyline.getId(); + } + } + + @Override + public void removePolyline(String polylineId) { + Object polyline = polylines.remove(polylineId); + if (polyline != null) { + try { + java.lang.reflect.Method removeMethod = polyline.getClass().getMethod("remove"); + removeMethod.invoke(polyline); + } catch (Exception e) { + Log.e(TAG, "Failed to remove polyline", e); + } + } + } + + @Override + public void clearPolylines() { + for (Object polyline : polylines.values()) { + try { + java.lang.reflect.Method removeMethod = polyline.getClass().getMethod("remove"); + removeMethod.invoke(polyline); + } catch (Exception e) { + Log.e(TAG, "Failed to remove polyline", e); + } + } + polylines.clear(); + } + + @Override + public void moveCamera(double latitude, double longitude, float zoom) { + if (aMap == null) return; + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(latitude, longitude); + + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + // com.amap.api.maps.CameraUpdateFactory + Class cameraUpdateFactoryClass = Class.forName("com.amap.api.maps.CameraUpdateFactory"); + java.lang.reflect.Method newLatLngZoomMethod = cameraUpdateFactoryClass.getMethod( + "newLatLngZoom", latLngClass, float.class); + Object cameraUpdate = newLatLngZoomMethod.invoke(null, latLng, zoom); + + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method moveCameraMethod = aMapClass.getMethod("moveCamera", + Class.forName("com.amap.api.maps.CameraUpdate")); + moveCameraMethod.invoke(aMap, cameraUpdate); + } catch (Exception e) { + Log.e(TAG, "Failed to move camera", e); + } + } + + @Override + public void animateCamera(double latitude, double longitude, float zoom, Runnable callback) { + if (aMap == null) return; + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(latitude, longitude); + + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + // com.amap.api.maps.CameraUpdateFactory + Class cameraUpdateFactoryClass = Class.forName("com.amap.api.maps.CameraUpdateFactory"); + java.lang.reflect.Method newLatLngZoomMethod = cameraUpdateFactoryClass.getMethod( + "newLatLngZoom", latLngClass, float.class); + Object cameraUpdate = newLatLngZoomMethod.invoke(null, latLng, zoom); + + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + + if (callback != null) { + // 创建回调 + // com.amap.api.maps.AMap.CancelableCallback + Class cancelableCallbackClass = Class.forName("com.amap.api.maps.AMap$CancelableCallback"); + Object callbackObj = java.lang.reflect.Proxy.newProxyInstance( + cancelableCallbackClass.getClassLoader(), + new Class[]{cancelableCallbackClass}, + (proxy, method, args) -> { + if (method.getName().equals("onFinish") || method.getName().equals("onCancel")) { + callback.run(); + } + return null; + } + ); + + java.lang.reflect.Method animateCameraMethod = aMapClass.getMethod("animateCamera", + Class.forName("com.amap.api.maps.CameraUpdate"), + long.class, + cancelableCallbackClass); + animateCameraMethod.invoke(aMap, cameraUpdate, 1000L, callbackObj); + } else { + java.lang.reflect.Method animateCameraMethod = aMapClass.getMethod("animateCamera", + Class.forName("com.amap.api.maps.CameraUpdate")); + animateCameraMethod.invoke(aMap, cameraUpdate); + } + } catch (Exception e) { + Log.e(TAG, "Failed to animate camera", e); + } + } + + @Override + public void setOnMapClickListener(OnMapClickListener listener) { + this.onMapClickListener = listener; + if (aMap == null) return; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class onMapClickListenerClass = Class.forName("com.amap.api.maps.AMap$OnMapClickListener"); + + Object listenerObj = java.lang.reflect.Proxy.newProxyInstance( + onMapClickListenerClass.getClassLoader(), + new Class[]{onMapClickListenerClass}, + (proxy, method, args) -> { + if (method.getName().equals("onMapClick")) { + Object latLng = args[0]; + // 3D SDK中,LatLng的latitude和longitude是public字段 + java.lang.reflect.Field latitudeField = latLng.getClass().getField("latitude"); + java.lang.reflect.Field longitudeField = latLng.getClass().getField("longitude"); + double lat = latitudeField.getDouble(latLng); + double lon = longitudeField.getDouble(latLng); + + // 坐标转换:GCJ02 -> WGS84 + double[] wgs84 = CoordinateConverter.gcj02ToWgs84(lat, lon); + if (onMapClickListener != null) { + onMapClickListener.onMapClick(wgs84[0], wgs84[1]); + } + } + return null; + } + ); + + java.lang.reflect.Method setOnMapClickListenerMethod = aMapClass.getMethod("setOnMapClickListener", onMapClickListenerClass); + setOnMapClickListenerMethod.invoke(aMap, listenerObj); + } catch (Exception e) { + Log.e(TAG, "Failed to set map click listener", e); + } + } + + @Override + public void setOnMarkerClickListener(OnMarkerClickListener listener) { + this.onMarkerClickListener = listener; + if (aMap == null) return; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class onMarkerClickListenerClass = Class.forName("com.amap.api.maps.AMap$OnMarkerClickListener"); + + Object listenerObj = java.lang.reflect.Proxy.newProxyInstance( + onMarkerClickListenerClass.getClassLoader(), + new Class[]{onMarkerClickListenerClass}, + (proxy, method, args) -> { + if (method.getName().equals("onMarkerClick")) { + Object marker = args[0]; + String markerId = findMarkerId(marker); + if (onMarkerClickListener != null) { + return onMarkerClickListener.onMarkerClick(markerId); + } + } + return false; + } + ); + + java.lang.reflect.Method setOnMarkerClickListenerMethod = aMapClass.getMethod("setOnMarkerClickListener", onMarkerClickListenerClass); + setOnMarkerClickListenerMethod.invoke(aMap, listenerObj); + } catch (Exception e) { + Log.e(TAG, "Failed to set marker click listener", e); + } + } + + private String findMarkerId(Object marker) { + for (Map.Entry entry : markers.entrySet()) { + if (entry.getValue().equals(marker)) { + return entry.getKey(); + } + } + return null; + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + // 高德地图可能不支持setPadding,或者API不同 + // 需要根据实际SDK调整 + Log.d(TAG, "setPadding called: " + left + ", " + top + ", " + right + ", " + bottom); + } + + @Override + public CameraPosition getCameraPosition() { + if (aMap == null) return null; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method getCameraPositionMethod = aMapClass.getMethod("getCameraPosition"); + Object cameraPosition = getCameraPositionMethod.invoke(aMap); + + // com.amap.api.maps.model.CameraPosition + // 3D SDK中,target是public字段 + java.lang.reflect.Field targetField = cameraPosition.getClass().getField("target"); + Object target = targetField.get(cameraPosition); + + // LatLng的latitude和longitude是public字段 + java.lang.reflect.Field latitudeField = target.getClass().getField("latitude"); + java.lang.reflect.Field longitudeField = target.getClass().getField("longitude"); + double lat = latitudeField.getDouble(target); + double lon = longitudeField.getDouble(target); + + // zoom也是public字段 + java.lang.reflect.Field zoomField = cameraPosition.getClass().getField("zoom"); + float zoom = zoomField.getFloat(cameraPosition); + + // 坐标转换:GCJ02 -> WGS84 + double[] wgs84 = CoordinateConverter.gcj02ToWgs84(lat, lon); + + return new CameraPosition(wgs84[0], wgs84[1], zoom); + } catch (Exception e) { + Log.e(TAG, "Failed to get camera position", e); + return null; + } + } + + @Override + public void setMyLocationButtonEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setMyLocationButtonEnabledMethod = uiSettingsClass.getMethod("setMyLocationButtonEnabled", boolean.class); + setMyLocationButtonEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set my location button enabled", e); + } + } + + @Override + public void setRotateGesturesEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setRotateGesturesEnabledMethod = uiSettingsClass.getMethod("setRotateGesturesEnabled", boolean.class); + setRotateGesturesEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set rotate gestures enabled", e); + } + } + + @Override + public void setCompassEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setCompassEnabledMethod = uiSettingsClass.getMethod("setCompassEnabled", boolean.class); + setCompassEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set compass enabled", e); + } + } + + @Override + public void setMapToolbarEnabled(boolean enabled) { + // 高德地图可能没有工具栏,或者API不同 + Log.d(TAG, "setMapToolbarEnabled called: " + enabled); + } + + @Override + public void clear() { + clearMarkers(); + clearPolylines(); + } + + @Override + public View getMapView() { + return mapView; + } + + /** + * 在Activity的onResume中调用 + */ + public void onResume() { + if (mapView != null) { + try { + java.lang.reflect.Method onResumeMethod = mapView.getClass().getMethod("onResume"); + onResumeMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onResume on map view", e); + } + } + } + + /** + * 在Activity的onPause中调用 + */ + public void onPause() { + if (mapView != null) { + try { + java.lang.reflect.Method onPauseMethod = mapView.getClass().getMethod("onPause"); + onPauseMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onPause on map view", e); + } + } + } + + /** + * 在Activity的onDestroy中调用 + */ + public void onDestroy() { + if (mapView != null) { + try { + java.lang.reflect.Method onDestroyMethod = mapView.getClass().getMethod("onDestroy"); + onDestroyMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onDestroy on map view", e); + } + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java new file mode 100644 index 00000000..ff026d6c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java @@ -0,0 +1,89 @@ +package dev.wander.android.opentagviewer.ui.maps; + +/** + * 坐标系转换工具 + * Google Maps使用WGS84坐标系(GPS原始坐标) + * 高德地图使用GCJ02坐标系(火星坐标,中国加密后的坐标) + */ +public class CoordinateConverter { + + private static final double PI = 3.1415926535897932384626; + private static final double A = 6378245.0; // 长半轴 + private static final double EE = 0.00669342162296594323; // 偏心率平方 + + /** + * WGS84转GCJ02(GPS坐标转火星坐标) + * @param wgsLat WGS84纬度 + * @param wgsLon WGS84经度 + * @return GCJ02坐标 [纬度, 经度] + */ + public static double[] wgs84ToGcj02(double wgsLat, double wgsLon) { + if (isOutOfChina(wgsLat, wgsLon)) { + return new double[]{wgsLat, wgsLon}; + } + double dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0); + double dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0); + double radLat = wgsLat / 180.0 * PI; + double magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + double sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); + dLon = (dLon * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); + double mgLat = wgsLat + dLat; + double mgLon = wgsLon + dLon; + return new double[]{mgLat, mgLon}; + } + + /** + * GCJ02转WGS84(火星坐标转GPS坐标) + * @param gcjLat GCJ02纬度 + * @param gcjLon GCJ02经度 + * @return WGS84坐标 [纬度, 经度] + */ + public static double[] gcj02ToWgs84(double gcjLat, double gcjLon) { + if (isOutOfChina(gcjLat, gcjLon)) { + return new double[]{gcjLat, gcjLon}; + } + double dLat = transformLat(gcjLon - 105.0, gcjLat - 35.0); + double dLon = transformLon(gcjLon - 105.0, gcjLat - 35.0); + double radLat = gcjLat / 180.0 * PI; + double magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + double sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); + dLon = (dLon * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); + double mgLat = gcjLat + dLat; + double mgLon = gcjLon + dLon; + return new double[]{gcjLat * 2 - mgLat, gcjLon * 2 - mgLon}; + } + + /** + * 判断是否在中国范围外 + */ + private static boolean isOutOfChina(double lat, double lon) { + return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271; + } + + /** + * 纬度转换 + */ + private static double transformLat(double x, double y) { + double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0; + ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; + return ret; + } + + /** + * 经度转换 + */ + private static double transformLon(double x, double y) { + double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0; + ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0; + return ret; + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java new file mode 100644 index 00000000..f2636bf1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java @@ -0,0 +1,360 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.BitmapDescriptor; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.MapStyleOptions; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.Polyline; +import com.google.android.gms.maps.model.PolylineOptions; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import dev.wander.android.opentagviewer.R; + +/** + * Google Maps地图提供商实现 + */ +public class GoogleMapProvider implements IMapProvider, OnMapReadyCallback { + private static final String TAG = GoogleMapProvider.class.getSimpleName(); + + private Activity activity; + private GoogleMap googleMap; + private SupportMapFragment mapFragment; + private OnMapReadyCallback callback; + + // 存储标记点和路径线 + private final Map markers = new HashMap<>(); + private final Map polylines = new HashMap<>(); + + // 监听器 + private OnMapClickListener onMapClickListener; + private OnMarkerClickListener onMarkerClickListener; + + @Override + public void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback) { + this.activity = activity; + this.callback = callback; + + if (!(activity instanceof androidx.fragment.app.FragmentActivity)) { + throw new IllegalArgumentException("Activity must be a FragmentActivity to use Google Maps"); + } + + FragmentManager fragmentManager = ((androidx.fragment.app.FragmentActivity) activity).getSupportFragmentManager(); + + // 查找或创建SupportMapFragment + mapFragment = (SupportMapFragment) fragmentManager.findFragmentById(containerViewId); + if (mapFragment == null) { + mapFragment = SupportMapFragment.newInstance(); + fragmentManager.beginTransaction() + .replace(containerViewId, mapFragment) + .commit(); + } + + mapFragment.getMapAsync(this); + } + + @Override + public void onMapReady(@NonNull GoogleMap googleMap) { + this.googleMap = googleMap; + + // 设置默认UI设置 + googleMap.getUiSettings().setMyLocationButtonEnabled(false); + googleMap.getUiSettings().setRotateGesturesEnabled(false); + googleMap.getUiSettings().setCompassEnabled(false); + googleMap.getUiSettings().setMapToolbarEnabled(false); + + // 设置监听器 + if (onMapClickListener != null) { + googleMap.setOnMapClickListener(point -> { + if (onMapClickListener != null) { + onMapClickListener.onMapClick(point.latitude, point.longitude); + } + }); + } + + if (onMarkerClickListener != null) { + googleMap.setOnMarkerClickListener(marker -> { + if (onMarkerClickListener != null) { + // 查找标记点ID + String markerId = findMarkerId(marker); + return onMarkerClickListener.onMarkerClick(markerId); + } + return false; + }); + } + + // 通知回调 + if (callback != null) { + callback.onMapReady(this); + } + } + + private String findMarkerId(Marker marker) { + for (Map.Entry entry : markers.entrySet()) { + if (entry.getValue().equals(marker)) { + return entry.getKey(); + } + } + return null; + } + + @Override + public void setMapStyle(boolean darkMode) { + if (googleMap == null) return; + + if (darkMode) { + MapStyleOptions style = MapStyleOptions.loadRawResourceStyle( + activity.getApplicationContext(), + R.raw.map_dark_style + ); + googleMap.setMapStyle(style); + } else { + googleMap.setMapStyle(null); + } + } + + @Override + public String addMarker(MapMarker marker) { + if (googleMap == null) { + Log.w(TAG, "GoogleMap is not ready yet, cannot add marker"); + return marker.getId(); + } + + LatLng position = new LatLng(marker.getLatitude(), marker.getLongitude()); + MarkerOptions options = new MarkerOptions() + .position(position) + .title(marker.getTitle()) + .snippet(marker.getSnippet()) + .draggable(marker.isDraggable()) + .visible(marker.isVisible()) + .alpha(marker.getAlpha()); + + // 设置图标 + if (marker.getIconBitmap() != null) { + try { + options.icon(BitmapDescriptorFactory.fromBitmap(marker.getIconBitmap())); + } catch (Exception e) { + Log.e(TAG, "Failed to create icon from bitmap, falling back to default", e); + options.icon(BitmapDescriptorFactory.defaultMarker()); + } + } else if (marker.getIconResourceId() != 0) { + options.icon(BitmapDescriptorFactory.fromResource(marker.getIconResourceId())); + } else if (!marker.isUseDefaultIcon()) { + // 如果没有指定图标且不使用默认图标,使用默认图标 + options.icon(BitmapDescriptorFactory.defaultMarker()); + } + + Marker googleMarker = googleMap.addMarker(options); + markers.put(marker.getId(), googleMarker); + + return marker.getId(); + } + + @Override + public void removeMarker(String markerId) { + Marker marker = markers.remove(markerId); + if (marker != null) { + marker.remove(); + } + } + + @Override + public void clearMarkers() { + for (Marker marker : markers.values()) { + marker.remove(); + } + markers.clear(); + } + + @Override + public String addPolyline(MapPolyline polyline) { + if (googleMap == null) { + Log.w(TAG, "GoogleMap is not ready yet, cannot add polyline"); + return polyline.getId(); + } + + List points = polyline.getPoints().stream() + .map(p -> new LatLng(p.getLatitude(), p.getLongitude())) + .collect(Collectors.toList()); + + PolylineOptions options = new PolylineOptions() + .addAll(points) + .color(polyline.getColor()) + .width(polyline.getWidth()) + .zIndex(polyline.getZIndex()) + .geodesic(polyline.isGeodesic()) + .visible(polyline.isVisible()) + .clickable(false); + + // 设置透明度 + int alpha = (int) (polyline.getAlpha() * 255); + int colorWithAlpha = (alpha << 24) | (polyline.getColor() & 0x00FFFFFF); + options.color(colorWithAlpha); + + Polyline googlePolyline = googleMap.addPolyline(options); + polylines.put(polyline.getId(), googlePolyline); + + return polyline.getId(); + } + + @Override + public void removePolyline(String polylineId) { + Polyline polyline = polylines.remove(polylineId); + if (polyline != null) { + polyline.remove(); + } + } + + @Override + public void clearPolylines() { + for (Polyline polyline : polylines.values()) { + polyline.remove(); + } + polylines.clear(); + } + + @Override + public void moveCamera(double latitude, double longitude, float zoom) { + if (googleMap == null) return; + LatLng position = new LatLng(latitude, longitude); + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); + } + + @Override + public void animateCamera(double latitude, double longitude, float zoom, Runnable callback) { + if (googleMap == null) return; + LatLng position = new LatLng(latitude, longitude); + + if (callback != null) { + googleMap.animateCamera( + CameraUpdateFactory.newLatLngZoom(position, zoom), + new GoogleMap.CancelableCallback() { + @Override + public void onFinish() { + callback.run(); + } + + @Override + public void onCancel() { + callback.run(); + } + } + ); + } else { + googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); + } + } + + @Override + public void setOnMapClickListener(OnMapClickListener listener) { + this.onMapClickListener = listener; + if (googleMap != null && listener != null) { + googleMap.setOnMapClickListener(point -> { + listener.onMapClick(point.latitude, point.longitude); + }); + } + } + + @Override + public void setOnMarkerClickListener(OnMarkerClickListener listener) { + this.onMarkerClickListener = listener; + if (googleMap != null && listener != null) { + googleMap.setOnMarkerClickListener(marker -> { + String markerId = findMarkerId(marker); + return listener.onMarkerClick(markerId); + }); + } + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + if (googleMap == null) return; + googleMap.setPadding(left, top, right, bottom); + } + + @Override + public CameraPosition getCameraPosition() { + if (googleMap == null) return null; + com.google.android.gms.maps.model.CameraPosition position = googleMap.getCameraPosition(); + return new CameraPosition( + position.target.latitude, + position.target.longitude, + position.zoom + ); + } + + @Override + public void setMyLocationButtonEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setMyLocationButtonEnabled(enabled); + } + + @Override + public void setRotateGesturesEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setRotateGesturesEnabled(enabled); + } + + @Override + public void setCompassEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setCompassEnabled(enabled); + } + + @Override + public void setMapToolbarEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setMapToolbarEnabled(enabled); + } + + @Override + public void clear() { + clearMarkers(); + clearPolylines(); + } + + @Override + public View getMapView() { + return mapFragment != null ? mapFragment.getView() : null; + } + + /** + * 获取底层的GoogleMap对象(用于特殊操作) + */ + public GoogleMap getGoogleMap() { + return googleMap; + } + + /** + * 动画移动到边界框 + */ + public void animateCameraToBounds(List points, int padding) { + if (googleMap == null || points == null || points.size() < 2) return; + + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + for (MapPolyline.LatLng point : points) { + builder.include(new LatLng(point.getLatitude(), point.getLongitude())); + } + LatLngBounds bounds = builder.build(); + googleMap.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java new file mode 100644 index 00000000..25a302d3 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java @@ -0,0 +1,206 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.view.View; + +/** + * 地图提供商抽象接口 + * 定义统一的地图操作API,支持多地图SDK切换 + * + * 实现类: + * - GoogleMapProvider: Google Maps实现 + * - AMapProvider: 高德地图实现 + */ +public interface IMapProvider { + + /** + * 初始化地图 + * @param activity 当前Activity + * @param containerViewId 地图容器View的ID + * @param callback 地图就绪回调 + */ + void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback); + + /** + * 设置地图样式 + * @param darkMode true为夜间模式,false为白天模式 + */ + void setMapStyle(boolean darkMode); + + /** + * 添加标记点 + * @param marker 标记点数据 + * @return 标记点ID + */ + String addMarker(MapMarker marker); + + /** + * 移除标记点 + * @param markerId 标记点ID + */ + void removeMarker(String markerId); + + /** + * 清除所有标记点 + */ + void clearMarkers(); + + /** + * 添加路径线 + * @param polyline 路径线数据 + * @return 路径线ID + */ + String addPolyline(MapPolyline polyline); + + /** + * 移除路径线 + * @param polylineId 路径线ID + */ + void removePolyline(String polylineId); + + /** + * 清除所有路径线 + */ + void clearPolylines(); + + /** + * 移动相机(不带动画) + * @param latitude 纬度 + * @param longitude 经度 + * @param zoom 缩放级别 + */ + void moveCamera(double latitude, double longitude, float zoom); + + /** + * 动画移动相机 + * @param latitude 纬度 + * @param longitude 经度 + * @param zoom 缩放级别 + * @param callback 动画完成回调(可为null) + */ + void animateCamera(double latitude, double longitude, float zoom, Runnable callback); + + /** + * 设置地图点击监听器 + * @param listener 点击监听器 + */ + void setOnMapClickListener(OnMapClickListener listener); + + /** + * 设置标记点击监听器 + * @param listener 点击监听器 + */ + void setOnMarkerClickListener(OnMarkerClickListener listener); + + /** + * 设置地图内边距 + * @param left 左边距 + * @param top 上边距 + * @param right 右边距 + * @param bottom 下边距 + */ + void setPadding(int left, int top, int right, int bottom); + + /** + * 获取当前相机位置 + * @return 相机位置信息 + */ + CameraPosition getCameraPosition(); + + /** + * 设置我的位置按钮是否可见 + * @param enabled true为可见 + */ + void setMyLocationButtonEnabled(boolean enabled); + + /** + * 设置旋转手势是否可用 + * @param enabled true为可用 + */ + void setRotateGesturesEnabled(boolean enabled); + + /** + * 设置指南针是否可见 + * @param enabled true为可见 + */ + void setCompassEnabled(boolean enabled); + + /** + * 设置地图工具栏是否可见 + * @param enabled true为可见 + */ + void setMapToolbarEnabled(boolean enabled); + + /** + * 清除地图上所有覆盖物(标记点、路径线等) + */ + void clear(); + + /** + * 获取地图View对象 + * @return 地图View + */ + View getMapView(); + + /** + * 地图就绪回调接口 + */ + interface OnMapReadyCallback { + /** + * 地图已就绪,可以开始操作 + * @param mapProvider 地图提供商实例 + */ + void onMapReady(IMapProvider mapProvider); + } + + /** + * 地图点击监听器 + */ + interface OnMapClickListener { + /** + * 地图被点击 + * @param latitude 点击位置的纬度 + * @param longitude 点击位置的经度 + */ + void onMapClick(double latitude, double longitude); + } + + /** + * 标记点击监听器 + */ + interface OnMarkerClickListener { + /** + * 标记点被点击 + * @param markerId 标记点ID + * @return true表示消费事件,false表示不消费 + */ + boolean onMarkerClick(String markerId); + } + + /** + * 相机位置信息 + */ + class CameraPosition { + private final double latitude; + private final double longitude; + private final float zoom; + + public CameraPosition(double latitude, double longitude, float zoom) { + this.latitude = latitude; + this.longitude = longitude; + this.zoom = zoom; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public float getZoom() { + return zoom; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java new file mode 100644 index 00000000..10499f8e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java @@ -0,0 +1,200 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.graphics.Bitmap; +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.Nullable; + +/** + * 地图标记点数据类 + */ +public class MapMarker { + private final double latitude; + private final double longitude; + private final String title; + private final String snippet; + private final String id; + + // 图标相关 + @Nullable + private final Bitmap iconBitmap; + @DrawableRes + private final int iconResourceId; + private final boolean useDefaultIcon; + + // 标记颜色(用于默认图标) + @ColorInt + private final int markerColor; + + // 是否可拖拽 + private final boolean draggable; + + // 是否可见 + private final boolean visible; + + // 透明度(0.0 - 1.0) + private final float alpha; + + private MapMarker(Builder builder) { + this.latitude = builder.latitude; + this.longitude = builder.longitude; + this.title = builder.title; + this.snippet = builder.snippet; + this.id = builder.id; + this.iconBitmap = builder.iconBitmap; + this.iconResourceId = builder.iconResourceId; + this.useDefaultIcon = builder.useDefaultIcon; + this.markerColor = builder.markerColor; + this.draggable = builder.draggable; + this.visible = builder.visible; + this.alpha = builder.alpha; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public String getTitle() { + return title; + } + + public String getSnippet() { + return snippet; + } + + public String getId() { + return id; + } + + @Nullable + public Bitmap getIconBitmap() { + return iconBitmap; + } + + @DrawableRes + public int getIconResourceId() { + return iconResourceId; + } + + public boolean isUseDefaultIcon() { + return useDefaultIcon; + } + + @ColorInt + public int getMarkerColor() { + return markerColor; + } + + public boolean isDraggable() { + return draggable; + } + + public boolean isVisible() { + return visible; + } + + public float getAlpha() { + return alpha; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private double latitude; + private double longitude; + private String title; + private String snippet; + private String id; + private Bitmap iconBitmap; + @DrawableRes + private int iconResourceId = 0; + private boolean useDefaultIcon = true; + @ColorInt + private int markerColor = 0xFF000000; // 默认黑色 + private boolean draggable = false; + private boolean visible = true; + private float alpha = 1.0f; + + public Builder latitude(double latitude) { + this.latitude = latitude; + return this; + } + + public Builder longitude(double longitude) { + this.longitude = longitude; + return this; + } + + public Builder position(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder snippet(String snippet) { + this.snippet = snippet; + return this; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder icon(Bitmap iconBitmap) { + this.iconBitmap = iconBitmap; + this.useDefaultIcon = false; + return this; + } + + public Builder iconResource(@DrawableRes int iconResourceId) { + this.iconResourceId = iconResourceId; + this.useDefaultIcon = false; + return this; + } + + public Builder useDefaultIcon(boolean useDefaultIcon) { + this.useDefaultIcon = useDefaultIcon; + return this; + } + + public Builder markerColor(@ColorInt int markerColor) { + this.markerColor = markerColor; + return this; + } + + public Builder draggable(boolean draggable) { + this.draggable = draggable; + return this; + } + + public Builder visible(boolean visible) { + this.visible = visible; + return this; + } + + public Builder alpha(float alpha) { + this.alpha = Math.max(0.0f, Math.min(1.0f, alpha)); + return this; + } + + public MapMarker build() { + if (id == null || id.isEmpty()) { + id = "marker_" + System.currentTimeMillis() + "_" + Math.random(); + } + return new MapMarker(this); + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java new file mode 100644 index 00000000..fd86033c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java @@ -0,0 +1,200 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import androidx.annotation.ColorInt; + +import java.util.List; + +/** + * 地图路径线数据类 + */ +public class MapPolyline { + private final List points; + private final String id; + + // 样式相关 + @ColorInt + private final int color; + private final float width; + private final float zIndex; + private final boolean geodesic; + private final boolean visible; + + // 透明度(0.0 - 1.0) + private final float alpha; + + // 虚线模式(某些地图SDK支持) + private final boolean dotted; + private final float[] pattern; // 虚线模式数组 + + private MapPolyline(Builder builder) { + this.points = builder.points; + this.id = builder.id; + this.color = builder.color; + this.width = builder.width; + this.zIndex = builder.zIndex; + this.geodesic = builder.geodesic; + this.visible = builder.visible; + this.alpha = builder.alpha; + this.dotted = builder.dotted; + this.pattern = builder.pattern; + } + + public List getPoints() { + return points; + } + + public String getId() { + return id; + } + + @ColorInt + public int getColor() { + return color; + } + + public float getWidth() { + return width; + } + + public float getZIndex() { + return zIndex; + } + + public boolean isGeodesic() { + return geodesic; + } + + public boolean isVisible() { + return visible; + } + + public float getAlpha() { + return alpha; + } + + public boolean isDotted() { + return dotted; + } + + public float[] getPattern() { + return pattern; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List points; + private String id; + @ColorInt + private int color = 0xFF0000FF; // 默认蓝色 + private float width = 10.0f; + private float zIndex = 0.0f; + private boolean geodesic = false; + private boolean visible = true; + private float alpha = 1.0f; + private boolean dotted = false; + private float[] pattern = null; + + public Builder points(List points) { + this.points = points; + return this; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder color(@ColorInt int color) { + this.color = color; + return this; + } + + public Builder width(float width) { + this.width = Math.max(1.0f, width); + return this; + } + + public Builder zIndex(float zIndex) { + this.zIndex = zIndex; + return this; + } + + public Builder geodesic(boolean geodesic) { + this.geodesic = geodesic; + return this; + } + + public Builder visible(boolean visible) { + this.visible = visible; + return this; + } + + public Builder alpha(float alpha) { + this.alpha = Math.max(0.0f, Math.min(1.0f, alpha)); + return this; + } + + public Builder dotted(boolean dotted) { + this.dotted = dotted; + return this; + } + + public Builder pattern(float[] pattern) { + this.pattern = pattern; + return this; + } + + public MapPolyline build() { + if (points == null || points.isEmpty()) { + throw new IllegalArgumentException("Polyline must have at least one point"); + } + if (id == null || id.isEmpty()) { + id = "polyline_" + System.currentTimeMillis() + "_" + Math.random(); + } + return new MapPolyline(this); + } + } + + /** + * 经纬度坐标点 + */ + public static class LatLng { + private final double latitude; + private final double longitude; + + public LatLng(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LatLng latLng = (LatLng) o; + return Double.compare(latLng.latitude, latitude) == 0 && + Double.compare(latLng.longitude, longitude) == 0; + } + + @Override + public int hashCode() { + long temp = Double.doubleToLongBits(latitude); + int result = (int) (temp ^ (temp >>> 32)); + temp = Double.doubleToLongBits(longitude); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + return result; + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java new file mode 100644 index 00000000..a4599be1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java @@ -0,0 +1,34 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.util.Log; + +/** + * 地图提供商工厂类 + * 根据用户设置创建对应的地图提供商实例 + */ +public class MapProviderFactory { + private static final String TAG = MapProviderFactory.class.getSimpleName(); + + public static final String PROVIDER_GOOGLE = "google"; + public static final String PROVIDER_AMAP = "amap"; + + /** + * 创建地图提供商实例 + * @param providerType 提供商类型 ("google" 或 "amap") + * @return 地图提供商实例 + */ + public static IMapProvider create(String providerType) { + if (providerType == null || providerType.isEmpty() || PROVIDER_GOOGLE.equals(providerType)) { + Log.d(TAG, "Creating Google Maps provider"); + return new GoogleMapProvider(); + } else if (PROVIDER_AMAP.equals(providerType)) { + Log.d(TAG, "Creating AMap provider"); + return new AMapProvider(); + } else { + Log.w(TAG, "Unknown provider type: " + providerType + ", defaulting to Google Maps"); + return new GoogleMapProvider(); + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java index bdd6a1cc..2cfe422a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java @@ -21,9 +21,6 @@ import androidx.core.content.res.ResourcesCompat; import androidx.core.graphics.drawable.DrawableCompat; -import com.google.android.gms.maps.model.BitmapDescriptor; -import com.google.android.gms.maps.model.BitmapDescriptorFactory; - import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -42,7 +39,7 @@ public final class VectorImageGeneratorUtil { private static final String TAG = VectorImageGeneratorUtil.class.getSimpleName(); - private static final Map BITMAP_CACHE = new HashMap<>(); + private static final Map BITMAP_CACHE = new HashMap<>(); @ColorInt private static final int COLOR_BLACK = 0xFF000000; @ColorInt private static final int COLOR_BLACK_ALPHA_100 = 0xD1000000; @@ -59,19 +56,19 @@ public final class VectorImageGeneratorUtil { private static final float EMOJI_TEXT_SIZE = 60; - public static BitmapDescriptor vectorToBitmap(@NonNull Resources resources, @DrawableRes int id) { + public static Bitmap vectorToBitmap(@NonNull Resources resources, @DrawableRes int id) { Drawable vectorDrawable = Objects.requireNonNull(ResourcesCompat.getDrawable(resources, id, null)); Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); vectorDrawable.draw(canvas); - return BitmapDescriptorFactory.fromBitmap(bitmap); + return bitmap; } /** * Create marker with emoji centred in the middle of it (cached) */ - public static BitmapDescriptor makeMarker(@NonNull Resources resources, final String emoji, @ColorInt int markerColor) { + public static Bitmap makeMarker(@NonNull Resources resources, final String emoji, @ColorInt int markerColor) { final String key = String.format(Locale.ROOT, "%s-%d", emoji, markerColor); if (BITMAP_CACHE.containsKey(key)) { return BITMAP_CACHE.get(key); @@ -93,15 +90,14 @@ public static BitmapDescriptor makeMarker(@NonNull Resources resources, final St final float emojiY = (markerDrawable.getIntrinsicHeight()/2.f) + (EMOJI_TEXT_SIZE/8f); canvas.drawText(emoji, emojiX, emojiY, paint); - var desc = BitmapDescriptorFactory.fromBitmap(bitmap); - BITMAP_CACHE.put(key, desc); - return desc; + BITMAP_CACHE.put(key, bitmap); + return bitmap; } /** * Create marker with some drawable resource in the middle of it (cached) */ - public static BitmapDescriptor makeMarker(@NonNull Resources resources, @DrawableRes int innerIcon, @ColorInt int markerColor, @ColorInt int iconColor) { + public static Bitmap makeMarker(@NonNull Resources resources, @DrawableRes int innerIcon, @ColorInt int markerColor, @ColorInt int iconColor) { final String key = String.format(Locale.ROOT, "%d-%d-%d", innerIcon, markerColor, iconColor); if (BITMAP_CACHE.containsKey(key)) { return BITMAP_CACHE.get(key); @@ -122,9 +118,8 @@ public static BitmapDescriptor makeMarker(@NonNull Resources resources, @Drawabl DrawableCompat.setTint(iconOnMarkerDrawable, iconColor); iconOnMarkerDrawable.draw(canvas); - var desc = BitmapDescriptorFactory.fromBitmap(bitmap); - BITMAP_CACHE.put(key, desc); - return desc; + BITMAP_CACHE.put(key, bitmap); + return bitmap; } private static void drawMarker(Canvas canvas, Drawable markerDrawable, @ColorInt int markerColor) { @@ -178,13 +173,17 @@ private static Bitmap drawShadow(Drawable markerDrawable) { var hardwareBuffer = Optional.ofNullable(image.getHardwareBuffer()).orElseThrow(() -> new RuntimeException("No HardwareBuffer")); var blurredBitmap = Optional.ofNullable(Bitmap.wrapHardwareBuffer(hardwareBuffer, null)).orElseThrow(() -> new RuntimeException("Create Bitmap failed")); + // 立即创建可变的 ARGB_8888 副本,避免 Hardware Bitmap 在跨线程使用时出现 BufferQueue 问题 + Bitmap softwareBitmap = blurredBitmap.copy(Bitmap.Config.ARGB_8888, true); + + // 清理 Hardware 资源 hardwareBuffer.close(); image.close(); imageReader.close(); renderNode.discardDisplayList(); hardwareRenderer.destroy(); - return blurredBitmap.copy(Bitmap.Config.ARGB_8888,true); + return softwareBitmap; } /** diff --git a/app/src/main/res/layout/activity_history_view.xml b/app/src/main/res/layout/activity_history_view.xml index 4eb5b56f..721fe824 100644 --- a/app/src/main/res/layout/activity_history_view.xml +++ b/app/src/main/res/layout/activity_history_view.xml @@ -27,18 +27,11 @@ android:layout_height="match_parent"> - - - + android:layout_height="match_parent" + android:background="@color/md_theme_background" + tools:context=".HistoryViewActivity" /> diff --git a/app/src/main/res/layout/activity_maps.xml b/app/src/main/res/layout/activity_maps.xml index 620cad71..3d62e05d 100644 --- a/app/src/main/res/layout/activity_maps.xml +++ b/app/src/main/res/layout/activity_maps.xml @@ -9,14 +9,12 @@ android:layout_height="match_parent" android:elevation="8dp"> - + android:background="@color/md_theme_background" + tools:context=".MapsActivity" /> + + @@ -74,6 +76,15 @@ app:onClick="@{onClickAnisetteServerUrl}" /> + + Export Logs Log file has been exported successfully Failed to export log file! + Map Provider + Google Maps + 高德地图 (AMap) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b97cb171..b04d30b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ googlePlacesLib = "4.1.0" desugarJdkLibs = "2.1.5" emojiPicker = "1.5.0" cronetEmbedded = "119.6045.31" +amapVersion = "9.8.3" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -75,6 +76,7 @@ androidx-emoji = { group = "androidx.emoji2", name = "emoji2", version.ref = "em androidx-emoji-views = { group = "androidx.emoji2", name = "emoji2-views", version.ref = "emojiPicker" } androidx-emoji-views-helper = { group = "androidx.emoji2", name = "emoji2-views-helper", version.ref = "emojiPicker" } androidx-emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" } +amap-map3d = { group = "com.amap.api", name = "3dmap", version.ref = "amapVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/local.defaults.properties b/local.defaults.properties index 5aa5c4a5..3cd81a50 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1 +1,2 @@ -MAPS_API_KEY=DEFAULT_API_KEY \ No newline at end of file +MAPS_API_KEY=DEFAULT_API_KEY +AMAP_API_KEY=DEFAULT_AMAP_API_KEY \ No newline at end of file From 962ee6ac55cc7d79e099f4f3c34e31629b017fe8 Mon Sep 17 00:00:00 2001 From: Gare Date: Sat, 8 Aug 2026 16:26:43 +0200 Subject: [PATCH 02/50] Update FindMy backend to 0.9.x, stub unicorn, add locales and startup picker Squashed from SadGare/OpenTagViewer (issue #44): 53c61d7, 719299f, eb48b8b and c2c0772. Deliberately excludes 4862cf7 - see below. FindMy.py 0.9.x makes FindMyAccessory stateful: it carries rolling-key alignment that has to be persisted between fetches, which is the issue #30 fix. That state is threaded end to end - OwnedBeacon gains accessory_json with a Migration(1, 2) to add the column, toAccessoryRequests backfills it from the retained plist for rows imported under 0.7.6, and storeFetchResult writes the updated alignment back after each fetch. Accounts saved by 0.7.6 cannot be restored by 0.9.x, so a restore failure clears the stale blob and routes the user to re-login. FindMy >= 0.9 pulls in anisette, which needs unicorn - a CPU emulator Chaquopy cannot build for Android. A pure-Python stub makes the tree resolve. This does not add local Anisette support; remote stays the only path, so #34 remains open. Also adds zh-rCN and zh-rTW locales, a startup language and map provider picker, provider-aware dark mode (#38), large-font clipping fixes, and restores the custom anisette input flow. 4862cf7 ("remote old findmy backend related code") is excluded on purpose. It was meant as dead-code cleanup but deleted the Migration(1, 2) and the lazy accessory_json backfill that 53c61d7 had correctly introduced, and added a test asserting they stay absent. Without the migration, every existing install throws "A migration from 1 to 2 was required but not found" on first database access after updating - reproducible only on upgrade, never on a fresh install. Closes #30, closes #38 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-debug.yml | 3 + .github/workflows/build-release.yml | 3 + .gitignore | 4 +- app/build.gradle.kts | 5 +- app/libs/unicorn-2.1.1-py3-none-any.whl | Bin 0 -> 2062 bytes .../opentagviewer/AppleLoginActivity.java | 39 +++- .../opentagviewer/HistoryViewActivity.java | 36 +-- .../android/opentagviewer/MapsActivity.java | 92 +++++--- .../opentagviewer/OpenAirTagApplication.java | 4 + .../opentagviewer/SettingsActivity.java | 42 +++- .../db/repo/BeaconRepository.java | 86 +++++++ .../db/room/OpenTagViewerDatabase.java | 18 +- .../db/room/dao/OwnedBeaconDao.java | 8 + .../db/room/entity/OwnedBeacon.java | 11 + .../python/AccessoryRequest.java | 21 ++ .../opentagviewer/python/FetchResult.java | 22 ++ .../python/PythonAppleService.java | 56 ++--- .../python/PythonAuthService.java | 7 +- .../opentagviewer/ui/maps/AMapProvider.java | 37 ++- .../ui/maps/GoogleMapProvider.java | 41 ++-- .../opentagviewer/ui/maps/IMapProvider.java | 9 +- .../settings/SharedMainSettingsManager.java | 68 +++++- .../util/android/LocaleConfigUtil.java | 10 + .../util/parse/AppleZipImporterUtil.java | 15 ++ app/src/main/python/main.py | 215 ++++++++++++------ app/src/main/res/layout/activity_maps.xml | 10 +- app/src/main/res/layout/main_app_settings.xml | 23 +- .../main/res/layout/map_provider_input.xml | 22 ++ app/src/main/res/layout/maps_tag_card.xml | 60 +++-- app/src/main/res/values-zh-rCN/strings.xml | 102 +++++++++ app/src/main/res/values-zh-rTW/strings.xml | 102 +++++++++ app/src/main/res/values/strings.xml | 6 +- app/src/main/res/xml/locales_config.xml | 4 +- scripts/rebuild_unicorn_stub_wheel.py | 134 +++++++++++ scripts/validate_stub_wheels.py | 81 +++++++ 35 files changed, 1181 insertions(+), 215 deletions(-) create mode 100644 app/libs/unicorn-2.1.1-py3-none-any.whl create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java create mode 100644 app/src/main/res/layout/map_provider_input.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values-zh-rTW/strings.xml create mode 100644 scripts/rebuild_unicorn_stub_wheel.py create mode 100644 scripts/validate_stub_wheels.py diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index c3a00ff7..1c8e240f 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -67,6 +67,9 @@ jobs: echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties echo "AMAP_API_KEY=${{ secrets.AMAP_API_KEY || 'amap_key_default_value' }}" >> secrets.properties + - name: Validate checked-in stub wheels + run: python3 scripts/validate_stub_wheels.py app/libs/unicorn-2.1.1-py3-none-any.whl + # Run Tests Build - name: Run gradle tests run: ./gradlew test diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7bf01e47..bdd923eb 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -81,6 +81,9 @@ jobs: run: | echo "${{ secrets.SIGNING_KEY }}" | base64 --decode > ${{ env.main_project_module }}/${{ env.KEYSTORE_FILE }} + - name: Validate checked-in stub wheels + run: python3 scripts/validate_stub_wheels.py app/libs/unicorn-2.1.1-py3-none-any.whl + - name: Run gradle tests run: ./gradlew test diff --git a/.gitignore b/.gitignore index 9987a8c3..9dec46db 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ __pycache__/ app/release .coverage .pytest_cache -.venv \ No newline at end of file +.venv +# Local Claude Code settings +.claude/settings.local.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff15a011..7ca15a0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,7 +90,10 @@ chaquopy { version = "3.12" pip { // SEE: https://chaquo.com/chaquopy/doc/current/android.html#android-requirements - install("FindMy==0.7.6") + // Stub wheel satisfies anisette's unicorn>=2.1.1 dependency without native compilation. + // We only use RemoteAnisetteProvider so unicorn is never actually instantiated. + install("libs/unicorn-2.1.1-py3-none-any.whl") + install("FindMy==0.9.8") install("NSKeyedUnArchiver==1.5") } } diff --git a/app/libs/unicorn-2.1.1-py3-none-any.whl b/app/libs/unicorn-2.1.1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..778bd1a36a4342a429ede819548a4847a4fc83dd GIT binary patch literal 2062 zcmWIWW@Zs#U|`^2SWq-G=4|uz^Ai{u7|NI#7=(eMrFogj`9*p9@$s2?nI-Y@dIgoW zlMZIxb`Wv>{#jJK`f`(D=i{!HW89%(g`T03SM!*ccD#IXR7&gYjEg7KcGtP=Gl&<` z@$QmQjotVA+}=Gq&j{Sl{mH&`l9P0+u6X^=whuqvGqP^qv}1C}fn_Y~QXH=d3Ei6H z<f0%ayCrX0g}il_AnMrbK&$Myy-&WE$6+qraJ#XSlPq?eNfK z+-|URlhJa+4^=iU;7!&^47JiN1BH~npmp{2@$U%xU| zZP8kMNl)vs5>I}T{+!>vHTPD{-`95Utnu9wjq*n(zCZW)>-Gb&dVedMef%fPzc_vB z{baj22J`<;-TuPx+(*~-X)&g&=am*6yD|5o<*~E=6Mu$DcW+J5>8qS})G+PGQHF#I znWY9+l5;b|ckC@;TYT-(mKfpElXqV}$oshM_NUr z>^#`)Y!mebM%-Z_7DI}_#G+g?llbKPyy6mI9CDZTmGgWuHSXjuD(x@l z4%iTC@Px}%>@#N&kmV%G&9>6mO_bZ*ge}wTuz(pO_v{7SvzuCd16!4O92QMu%UINw z5NOiwD$(fLyMqDM0V1yZEwq3sC=Qr{gpeEnO-Vq@=U#Hmb~5B)c(8lFQbmx)qUDVr z_~goNxTZYVdBx1GJNL6k(D84tk6(VdO#OZlw@LgYq4{$-a{d;vFbEXzUpppr^;T!X zE$MrOIjF~VXx10qa!zBhqaRNB*zB}@nH=N&pafRJ>DC%0s}-9 zxkQ2{+$ufN#kk_YDwbsuX$Je+w>5p}n<=vXsr0g7o)ZGy8oLaBubt7CcRJ;a+3t6{ zyg7Q5*i`BkHu70|fBtkP?)xKl_oME{7Nv(Z-NfaVwk*AR*r0pkUg_26lMM=rRz50t zbv{BoQ=(A8DoT9a5zDLR`FJa)Upn`5aii9WZU48V`Lgo9+0eT*QsRKntjhX|w~J=- zzwX`{6?pvD+Da z(lZ3Yl+5B1-ORkSe0^Wn5Jwls5J%sWw%msd1lS(f?tdg(>akFEb7!;n)TS-JXUY^M zyi<^8{lB-_d;Zs*#fx2f{~TM|m3hFV=bhtB=3QP}7QKr&{9>MMy4L-Mum5-GXeuN3O#P0<0JIF8{zJ9JK2*)56^XPP;{X?bj+8Czf#B+xO+|{ny+fRZh`@ zeE-bO<}o@4i}S`!TzjS5M6X@r_%+?*uj7{$@9Ndr;x=3wcBHakYa(LVpu!4>IQ4+iYK?w1 z;XuI<0Va)_zhd&*LYl$T# zXf6|PEe~)Ls;`t~igu{v=4ZwiMTDzbdfL@+K zv@ { Log.d(TAG, "Got error response from anisette server @ " + newUrl, error); @@ -242,9 +251,9 @@ private void hideLoading() { } private void onAnisetteUrlInputTyped(Boolean isValid) { - // typing overrides until explicitly validated by pressing the "confirm" - // checkmark to test the server - this.binding.setAllowServerConfNext(false); + // Let the user proceed after typing a syntactically valid URL. + // The actual connectivity test happens when the user continues. + this.binding.setAllowServerConfNext(isValid); } private void handleAuth(LoginActivityState state) { @@ -274,11 +283,21 @@ private void handleAuth(LoginActivityState state) { public void onClickToLoginAccount(View view) { Log.d(TAG, "Clicked onwards to account login!"); - // TODO: make a nice transition - if (this.binding.getAllowServerConfNext()) { + MaterialAutoCompleteTextView urlTextInput = findViewById(R.id.anisetteServerUrl); + final String currentInput = Optional.ofNullable(urlTextInput.getText()) + .map(CharSequence::toString) + .map(String::trim) + .orElse(""); + + if (!this.sharedMainSettingsManager.validateAnisetteUrl(currentInput)) { + this.binding.setAllowServerConfNext(false); + return; + } + + this.testAndSaveAnisetteUrl(currentInput, () -> { this.getUiState().setCurrentPage(PAGE.LOGIN); this.showAccountLoginAuthOptions(); - } + }); } public void onClickBackToAnisetteSettings(View view) { @@ -702,6 +721,12 @@ private void updateLocale(final String newLocale) { Log.i(TAG, "Updating app settings language"); } + private void updateMapProvider(final String newProvider) { + this.getUserSettings().setMapProvider(newProvider); + this.saveSettings(); + Log.i(TAG, "Updating app settings map provider to " + newProvider); + } + private void saveSettings() { var asyncOp = this.userSettingsRepo.storeUserSettings(this.getUserSettings()) .subscribe( @@ -731,4 +756,4 @@ enum SETUP_STATUS { SERVER_UNAVAILABLE, OK; } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index 08f15ee6..5f9112e1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -222,12 +222,7 @@ public void onMapReady(IMapProvider provider) { this.map = ((GoogleMapProvider) provider).getGoogleMap(); } - if (this.userSettings.hasDarkThemeEnabled()) { - // DARK THEME map - mapProvider.setMapStyle(true); - } else { - mapProvider.setMapStyle(false); - } + mapProvider.setMapStyle(this.getPreferredMapStyle()); // move to same position that we left when we went to the history page from the main page this.mapProvider.moveCamera(this.defaultLatitude, this.defaultLongitude, this.defaultZoom); @@ -238,6 +233,10 @@ public void onMapReady(IMapProvider provider) { @Override protected void onResume() { super.onResume(); + this.userSettings = this.userSettingsRepo.getUserSettings(); + if (this.mapProvider != null) { + this.mapProvider.setMapStyle(this.getPreferredMapStyle()); + } if (this.mapProvider instanceof AMapProvider) { ((AMapProvider) this.mapProvider).onResume(); } @@ -259,6 +258,15 @@ protected void onDestroy() { } } + private IMapProvider.MapStyle getPreferredMapStyle() { + if (this.userSettings == null || this.userSettings.getUseDarkTheme() == null) { + return IMapProvider.MapStyle.FOLLOW_SYSTEM; + } + return this.userSettings.getUseDarkTheme() + ? IMapProvider.MapStyle.DARK + : IMapProvider.MapStyle.LIGHT; + } + private void fetchAndUpdateDataForCurrentDay() { moveLeftButton.setClickable(false); // temp disable moveRightButton.setClickable(false); // temp disable @@ -365,7 +373,9 @@ private Observable> fetchReports(final long beginning final boolean isForToday = this.daysBack == 0; var reqData = Map.of(this.beaconId, this.beaconInformation.getOwnedBeaconPlistRaw()); - var asyncReq = this.appleService.getReportsBetween(reqData, beginningOfDay, endOfDay); + // asyncReq emits Observable (reports + updated accessory state per beacon) + var asyncReq = this.beaconRepo.toAccessoryRequests(reqData) + .flatMap(requests -> this.appleService.getReportsBetween(requests, beginningOfDay, endOfDay)); final long now = System.currentTimeMillis(); if (beginningOfDay < now - SEVEN_DAYS_IN_MS) { @@ -378,7 +388,7 @@ private Observable> fetchReports(final long beginning Log.d(TAG, "Going to perform a merged localdb + remote fetch for beaconId=" + beaconId + " location data in range: " + beginningOfDay + "-" + endOfDay); return Observable.zip( // try to fetch remotely anyways and combine uniquely later - asyncReq.flatMap(this.beaconRepo::storeToLocationCache).map(locations -> locations.get(beaconId)), + asyncReq.flatMap(this.beaconRepo::storeFetchResult).map(locations -> locations.get(beaconId)), // also try to fetch from DB for same time range asyncDB, (locationsRemote, locationsLocal) -> { @@ -401,14 +411,14 @@ private Observable> fetchReports(final long beginning Log.d(TAG, "Going to perform a fresh fetch for beaconId=" + beaconId + " location data in range: " + beginningOfDay + "-" + endOfDay); return asyncReq - .doOnNext(locations -> { + .doOnNext(fetchResult -> { // Don't cache the current day (it could still update)! if (!isForToday) { - MEMORY_REPORTS_CACHE.put(cacheKey, locations.get(beaconId)); + MEMORY_REPORTS_CACHE.put(cacheKey, fetchResult.getReports().get(beaconId)); } }) - .flatMap(locations -> this.storeLocationFetchToLocalDb(isForToday, beaconId, beginningOfDay).andThen(Observable.just(locations))) - .flatMap(this.beaconRepo::storeToLocationCache) + .flatMap(fetchResult -> this.storeLocationFetchToLocalDb(isForToday, beaconId, beginningOfDay).andThen(Observable.just(fetchResult))) + .flatMap(this.beaconRepo::storeFetchResult) .map(locations -> locations.get(beaconId)) .subscribeOn(Schedulers.computation()); } @@ -777,4 +787,4 @@ private void animateCameraToPos(double latitude, double longitude, float zoom, R if (this.mapProvider == null) return; this.mapProvider.animateCamera(latitude, longitude, zoom, onFinished); } -} \ No newline at end of file +} 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 e7e5179e..13c80528 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -42,15 +42,9 @@ import com.google.android.gms.location.FusedLocationProviderClient; import com.google.android.gms.location.LocationServices; -import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.OnMapReadyCallback; -import com.google.android.gms.maps.SupportMapFragment; import com.google.android.gms.maps.model.LatLng; -import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.Marker; -import com.google.android.gms.maps.model.MarkerOptions; -import com.google.android.gms.maps.GoogleMap.OnMapClickListener; import dev.wander.android.opentagviewer.ui.maps.IMapProvider; import dev.wander.android.opentagviewer.ui.maps.MapProviderFactory; @@ -91,6 +85,7 @@ import dev.wander.android.opentagviewer.db.repo.model.ImportData; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; import dev.wander.android.opentagviewer.python.PythonAppleService; +import dev.wander.android.opentagviewer.python.PythonAccountLoginException; import dev.wander.android.opentagviewer.python.PythonAuthService; import dev.wander.android.opentagviewer.db.repo.UserDataRepository; import dev.wander.android.opentagviewer.ui.maps.TagCardHelper; @@ -190,6 +185,10 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa Intent data = result.getData(); if (data != null && data.getBooleanExtra("requestSendToLogin", false)) { this.handleSendToLogin(); + return; + } + if (data != null && data.getBooleanExtra("mapProviderChanged", false)) { + this.recreate(); } } } @@ -213,8 +212,8 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa if (result.getResultCode() == RESULT_OK) { Intent data = result.getData(); if (data != null && ( - data.getStringExtra("deviceWasRemoved") != null) - || data.getStringExtra("deviceWasChanged") != null) { + data.getStringExtra("deviceWasRemoved") != null + || data.getStringExtra("deviceWasChanged") != null)) { this.handleDeviceListChanged(); } } @@ -323,12 +322,7 @@ public void onMapReady(IMapProvider provider) { mapProvider.setCompassEnabled(false); // not needed due to no rotation being allowed mapProvider.setMapToolbarEnabled(false); // we have a custom button for this - if (this.userSettings.hasDarkThemeEnabled()) { - // DARK THEME map - mapProvider.setMapStyle(true); - } else { - mapProvider.setMapStyle(false); - } + mapProvider.setMapStyle(this.getPreferredMapStyle()); this.enableMyLocation(false); @@ -375,6 +369,11 @@ public void onMapClick(double latitude, double longitude) { protected void onResume() { super.onResume(); + this.userSettings = this.userSettingsRepo.getUserSettings(); + if (this.mapProvider != null) { + this.mapProvider.setMapStyle(this.getPreferredMapStyle()); + } + // TODO: when a user changes their anisette URL in settings and returns here, this should be able to deal with querying the new URL this.refreshIfAllowed(); @@ -803,11 +802,10 @@ private void getAppleUser(Optional userAuth) { this.sendToLogin(); return; } - // else stay here & restore the account & get the user settings - var userSettings = userSettingsRepo.getUserSettings(); - - // Get Apple account - var asyncAppleService = PythonAuthService.restoreAccount(userAuth.get(), userSettings.getAnisetteServerUrl()) + // else stay here & restore the account. + // Note: FindMy 0.9.x embeds the anisette URL in the account JSON itself, + // so we no longer need to read userSettings.getAnisetteServerUrl() here. + var asyncAppleService = PythonAuthService.restoreAccount(userAuth.get()) .map(appleAccount -> { this.appleService = PythonAppleService.setup(appleAccount); return this.appleService; @@ -857,11 +855,41 @@ private void getAppleUser(Optional userAuth) { this.initialFetchComplete = true; Log.e(TAG, "Error while restoring account and trying to get latest beacons", error); TagCardHelper.toggleRefreshLoadingAll(this.dynamicCardsForTag, false); + + if (isAccountRestoreFailure(error)) { + // Most likely cause: a session blob saved by FindMy 0.7.6 that 0.9.x cannot + // restore. Wipe the bad blob and route the user back to login with a hint. + Log.w(TAG, "Account restore failed; clearing saved auth and prompting re-login"); + handleAccountRestoreFailureOnUiThread(); + } //Toast.makeText(this.getApplicationContext(), "Error while trying to fetch data for beacons", LENGTH_SHORT).show(); // this error just happens every now and then. It's no big deal, we will retry automatically eventually... }); } + private static boolean isAccountRestoreFailure(Throwable t) { + for (Throwable cur = t; cur != null; cur = cur.getCause()) { + if (cur instanceof PythonAccountLoginException) { + return true; + } + } + return false; + } + + private void handleAccountRestoreFailureOnUiThread() { + var disposable = this.userAuthRepo.clearUser() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + Toast.makeText(this, R.string.relogin_required_after_upgrade, LENGTH_LONG).show(); + this.finish(); + this.sendToLogin(); + }, err -> { + Log.e(TAG, "Failed to clear stale auth blob during restore-failure recovery", err); + // Fall through silently — at worst the user has to log out manually. + }); + } + private synchronized void addBeaconToCurrent(final List newBeaconInformation) { newBeaconInformation.forEach(beacon -> { final String beaconId = beacon.getBeaconId(); @@ -1174,6 +1202,15 @@ private void sendToLogin() { startActivity(intent); } + private IMapProvider.MapStyle getPreferredMapStyle() { + if (this.userSettings == null || this.userSettings.getUseDarkTheme() == null) { + return IMapProvider.MapStyle.FOLLOW_SYSTEM; + } + return this.userSettings.getUseDarkTheme() + ? IMapProvider.MapStyle.DARK + : IMapProvider.MapStyle.LIGHT; + } + private void fetchAndUpdateCurrentBeacons() { var beacons = this.beacons.values().stream() .collect(Collectors.toMap(b -> b.getInfo().getBeaconId(), b -> b.getInfo().getOwnedBeaconPlistRaw())); @@ -1205,21 +1242,24 @@ private Observable>> fetchLastReports(fin ); Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(beaconIdToPlist, hoursToGoBack) + return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) + .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) .doOnNext(reports -> this.last24HHistoryFetchAt = now) // on success, update this time. - .flatMap(this.beaconRepo::storeToLocationCache); + .flatMap(this.beaconRepo::storeFetchResult); } private Observable>> fetchLastReports(final Map beaconIdToPlist, final int hoursToGoBack) { Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(beaconIdToPlist, hoursToGoBack) - .flatMap(this.beaconRepo::storeToLocationCache); + return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) + .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) + .flatMap(this.beaconRepo::storeFetchResult); } private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) { Log.i(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(Map.of(beaconId, pList), hoursToGoBack) - .flatMap(this.beaconRepo::storeToLocationCache); + return this.beaconRepo.toAccessoryRequests(Map.of(beaconId, pList)) + .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) + .flatMap(this.beaconRepo::storeFetchResult); } private boolean isAppleServiceInitialised() { @@ -1336,4 +1376,4 @@ public BeaconData(@lombok.NonNull BeaconInformation info, @lombok.NonNull List { + String appLocales = AppCompatDelegate.getApplicationLocales().toLanguageTags(); + if (appLocales != null && !appLocales.isBlank()) { + return appLocales.split(",")[0]; + } + return Locale.getDefault().toLanguageTag(); + }); var availableLocales = LocaleConfigUtil.getAvailableLocales(this.getResources()) .toArray(new String[0]); @@ -269,7 +289,8 @@ private void onClickEditLanguage() { .sorted().toArray(String[]::new)); mappedLocales.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(currentLocale)) + .filter(kvp -> kvp.getValue().equalsIgnoreCase(currentLocale) + || currentLocale.toLowerCase(Locale.ROOT).startsWith(kvp.getValue().toLowerCase(Locale.ROOT) + "-")) .findFirst() .map(Map.Entry::getKey) .ifPresent(option -> languageDropdown.setText(option, false)); @@ -296,6 +317,19 @@ private void onClickEditLanguage() { } private void onClickEditAnisetteServerUrl() { + // FindMy 0.9.x embeds the anisette URL inside the saved account JSON and + // refuses to swap providers post-login. Block changes while a session + // exists; the user can sign out below to change provider. + var existingAuth = this.authRepository.getUserAuth().blockingFirst(); + if (existingAuth.isPresent()) { + Toast.makeText( + this, + R.string.anisette_change_requires_logout, + Toast.LENGTH_LONG + ).show(); + return; + } + View view = inflate(this, R.layout.anisette_server_url_input_dialog, null); CircularProgressIndicatorSpec spec = new CircularProgressIndicatorSpec(view.getContext(), /* attrs= */ null, 0, com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall); @@ -520,7 +554,7 @@ private void saveSettings() { private String getPrettyLanguageName(final String languageId) { var res = this.getResources(); return res.getString(res.getIdentifier( - "lang_" + languageId, + LocaleConfigUtil.toLocaleLabelResourceName(languageId), "string", this.getPackageName())); } @@ -559,4 +593,4 @@ public void setButtonStage(boolean successStage) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index ff0fabd0..35788346 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -2,6 +2,9 @@ import android.util.Log; +import com.chaquo.python.Python; + +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -20,6 +23,8 @@ import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.FetchResult; import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -119,6 +124,87 @@ public Observable getById(final String beaconId) { }).subscribeOn(Schedulers.io()); } + /** + * Build the FindMy 0.9.x fetch input for the given beacons. For each beacon we use + * the persisted {@code accessory_json} if present, otherwise lazily backfill it + * from the legacy XML plist via {@code main.py:convertPlistToJson} (and persist + * the result so the next call is cheap). + * + * Beacons whose plist cannot be converted are dropped — passing them to Python + * would just throw inside FindMyAccessory.from_json. The caller will see a + * shorter fetch result than it asked for, which is preferable to taking down + * the whole batch. + * + * @param beaconIdToPlistFallback beacon ID → legacy XML plist (used only when the + * row's accessory_json is still NULL, e.g. for rows + * imported under FindMy 0.7.6). + */ + public Observable> toAccessoryRequests(Map beaconIdToPlistFallback) { + return Observable.fromCallable(() -> { + if (beaconIdToPlistFallback.isEmpty()) { + return java.util.Collections.emptyList(); + } + + final var dao = db.ownedBeaconDao(); + final var py = Python.getInstance(); + final var module = py.getModule("main"); + + List out = new ArrayList<>(beaconIdToPlistFallback.size()); + for (var entry : beaconIdToPlistFallback.entrySet()) { + final String beaconId = entry.getKey(); + final String plistFallback = entry.getValue(); + + String accessoryJson = null; + final OwnedBeacon row = dao.getById(beaconId); + if (row != null) { + accessoryJson = row.accessoryJson; + } + + if (accessoryJson == null) { + // Lazy backfill: rows imported under FindMy 0.7.6 won't have accessory_json. + try { + var converted = module.callAttr("convertPlistToJson", plistFallback); + if (converted != null) { + accessoryJson = converted.toString(); + dao.updateAccessoryJson(beaconId, accessoryJson); + Log.d(TAG, "Lazy-backfilled accessory_json for beaconId=" + beaconId); + } + } catch (Exception e) { + Log.w(TAG, "convertPlistToJson failed for beaconId=" + beaconId + + "; this beacon will be skipped this fetch", e); + } + } + + if (accessoryJson != null) { + out.add(new AccessoryRequest(beaconId, accessoryJson)); + } else { + Log.w(TAG, "Skipping beaconId=" + beaconId + " — no accessory_json available"); + } + } + return out; + }).subscribeOn(Schedulers.io()); + } + + /** + * Persist a {@link FetchResult} from {@code PythonAppleService}: location reports + * go to the cache (delegating to {@link #storeToLocationCache}), and the freshly + * serialized {@code accessory_json} per beacon (which now carries the rolling-key + * alignment from FindMy 0.9.x — the issue #30 fix) is written back to the + * {@code OwnedBeacons} table. + */ + public Observable>> storeFetchResult(FetchResult fetchResult) { + return Observable.fromCallable(() -> { + final var dao = db.ownedBeaconDao(); + for (var entry : fetchResult.getUpdatedAccessoryJson().entrySet()) { + if (entry.getValue() != null) { + dao.updateAccessoryJson(entry.getKey(), entry.getValue()); + } + } + return fetchResult.getReports(); + }).subscribeOn(Schedulers.io()) + .flatMap(this::storeToLocationCache); + } + public Observable>> storeToLocationCache(Map> reportsForBeaconId) { return Observable.fromCallable(() -> { if (reportsForBeaconId.isEmpty()) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java index b061a49c..c5b7d3f9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java @@ -2,9 +2,12 @@ import android.content.Context; +import androidx.annotation.NonNull; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; import dev.wander.android.opentagviewer.db.room.dao.BeaconNamingRecordDao; import dev.wander.android.opentagviewer.db.room.dao.DailyHistoryFetchRecordDao; @@ -28,11 +31,23 @@ DailyHistoryFetchRecord.class, UserBeaconOptions.class }, - version = 1 + version = 2 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; + /** + * v1 → v2: adds {@code accessory_json} to {@code OwnedBeacons} for FindMy 0.9.x's + * stateful FindMyAccessory persistence (issue #30 fix). Pure additive ALTER — + * existing rows survive with NULL and are lazily backfilled on first fetch. + */ + static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN accessory_json TEXT"); + } + }; + public static OpenTagViewerDatabase getInstance(Context context) { // Singleton pattern for single-process apps: https://developer.android.com/training/data-storage/room#java @@ -41,6 +56,7 @@ public static OpenTagViewerDatabase getInstance(Context context) { context, OpenTagViewerDatabase.class, "opentagviewer-db") + .addMigrations(MIGRATION_1_2) .build(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java index 00f17e02..35f1f483 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java @@ -27,6 +27,14 @@ public interface OwnedBeaconDao { @Query("UPDATE OwnedBeacons SET is_removed = 1 WHERE id = :beaconId") void setRemoved(String beaconId); + /** + * Persist the rolling-key alignment state (FindMy 0.9.x stateful FindMyAccessory) + * after a successful fetch. Targeted UPDATE so we don't risk clobbering other + * columns if the in-memory copy is stale. + */ + @Query("UPDATE OwnedBeacons SET accessory_json = :accessoryJson WHERE id = :beaconId") + void updateAccessoryJson(String beaconId, String accessoryJson); + @Delete void delete(OwnedBeacon ownedBeaconWithId); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java index 10cd9158..5ca15ead 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java @@ -45,4 +45,15 @@ public class OwnedBeacon { @ColumnInfo(name = "is_removed") public boolean isRemoved; + + /** + * Serialized FindMyAccessory state (JSON) for FindMy.py 0.9.x. Includes the + * rolling-key alignment that updates after every fetch — persisting it back + * across calls is what fixes the key-drift bug from issue #30. + * + * Nullable for rows imported under FindMy 0.7.6: lazily backfilled from + * {@link #content} on first fetch via {@code main.py:convertPlistToJson}. + */ + @ColumnInfo(name = "accessory_json") + public String accessoryJson; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java new file mode 100644 index 00000000..6a0a8c96 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java @@ -0,0 +1,21 @@ +package dev.wander.android.opentagviewer.python; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Java→Python carrier for a single beacon's fetch input. Chaquopy exposes the + * getter methods to Python as attribute access (e.g. {@code req.getBeaconId()}) + * so the Python side can read fields without having to import any Java types. + * + *

Replaces the older {@code Pair} (beaconId → plistXml) shape + * that the 0.7.x bridge used. With FindMy 0.9.x we also need the persisted + * accessory JSON (which carries the rolling-key alignment state — the issue #30 + * fix) so it can be passed in and updated state can be returned. + */ +@AllArgsConstructor +@Getter +public class AccessoryRequest { + private final String beaconId; + private final String accessoryJson; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java new file mode 100644 index 00000000..eaf31571 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java @@ -0,0 +1,22 @@ +package dev.wander.android.opentagviewer.python; + +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Wraps the result of a {@link PythonAppleService} fetch call. With FindMy 0.9.x + * the {@code FindMyAccessory} object is stateful — its rolling-key alignment is + * updated each fetch and must be persisted back to the DB to keep key drift + * from re-emerging. So a fetch returns both the location reports per beacon AND + * the freshly-serialized accessory JSON per beacon, keyed by beaconId. + */ +@AllArgsConstructor +@Getter +public class FetchResult { + private final Map> reports; + private final Map updatedAccessoryJson; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index fb9851e5..679e18d8 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -1,7 +1,6 @@ package dev.wander.android.opentagviewer.python; import android.util.Log; -import android.util.Pair; import com.chaquo.python.Kwarg; import com.chaquo.python.PyObject; @@ -13,7 +12,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import io.reactivex.rxjava3.core.Observable; @@ -41,28 +39,22 @@ private PythonAppleService(PythonAppleAccount account) { this.account = account; } - public Observable>> getLastReports(final Map beaconIdToPList, final int hoursToGoBack) { + public Observable getLastReports(final List requests, final int hoursToGoBack) { return Observable.fromCallable(() -> { - if (beaconIdToPList.isEmpty()) { - // if there's no items being requested, just return none immediately: - return Collections.>emptyMap(); + if (requests.isEmpty()) { + return emptyResult(); } var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); - var asListOfPairs = beaconIdToPList.entrySet().stream() - .map(kvp -> Pair.create(kvp.getKey(), kvp.getValue())) - .collect(Collectors.toList()); - var returned = module.callAttr( "getLastReports", new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToPList", asListOfPairs), + new Kwarg("idToAccessoryData", requests), new Kwarg("hoursBack", hoursToGoBack) ); - // extract to result list... if (returned == null) { Log.e(TAG, "python call to getLastReports resulted in error (check python logs for details)"); throw new PythonAppleFindMyException("Error while retrieving last reports for account via python!"); @@ -73,29 +65,23 @@ public Observable>> getLastReports(final }).subscribeOn(Schedulers.io()); } - public Observable>> getReportsBetween(final Map beaconIdToPList, final long startTimeUnixMS, final long endTimeUnixMS) { + public Observable getReportsBetween(final List requests, final long startTimeUnixMS, final long endTimeUnixMS) { return Observable.fromCallable(() -> { - if (beaconIdToPList.isEmpty()) { - // if there's no items being requested, just return none immediately: - return Collections.>emptyMap(); + if (requests.isEmpty()) { + return emptyResult(); } var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); - var asListOfPairs = beaconIdToPList.entrySet().stream() - .map(kvp -> Pair.create(kvp.getKey(), kvp.getValue())) - .collect(Collectors.toList()); - var returned = module.callAttr( "getReports", new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToPList", asListOfPairs), + new Kwarg("idToAccessoryData", requests), new Kwarg("unixStartMs", startTimeUnixMS), new Kwarg("unixEndMs", endTimeUnixMS) ); - // extract to result list... if (returned == null) { Log.e(TAG, "python call to getReports resulted in error (check python logs for details)"); throw new PythonAppleFindMyException("Error while retrieving time ranged reports for account via python!"); @@ -105,13 +91,27 @@ public Observable>> getReportsBetween(fin }).subscribeOn(Schedulers.io()); } + private static FetchResult emptyResult() { + return new FetchResult(Collections.emptyMap(), Collections.emptyMap()); + } - private static Map> mapResults(final PyObject locationReportsResult) { + /** + * Python returns a dict shaped: + * { beaconId: { "reports": [reportDict, ...], "updatedAccessoryJson": "" } } + * + * We split it back into two parallel maps so callers can persist the updated + * accessory JSON via the OwnedBeaconDao (Phase 3) while consuming reports as before. + */ + private static FetchResult mapResults(final PyObject locationReportsResult) { Map> results = new HashMap<>(); + Map updatedAccessoryJson = new HashMap<>(); var mapBeaconIdToResult = locationReportsResult.asMap(); for (var key : mapBeaconIdToResult.keySet()) { - var locationReportList = mapBeaconIdToResult.get(key).asList(); + var perBeacon = mapBeaconIdToResult.get(key).asMap(); + + var locationReportList = perBeacon.get("reports").asList(); + var updatedAccessory = perBeacon.get("updatedAccessoryJson"); List reports = new LinkedList<>(); final int numReports = locationReportList.size(); @@ -142,9 +142,13 @@ private static Map> mapResults(final PyObject } reports.sort(Comparator.comparingLong(BeaconLocationReport::getTimestamp)); - results.put(key.toString(), reports); + String beaconIdStr = key.toString(); + results.put(beaconIdStr, reports); + if (updatedAccessory != null) { + updatedAccessoryJson.put(beaconIdStr, updatedAccessory.toString()); + } } - return results; + return new FetchResult(results, updatedAccessoryJson); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java index 61444d08..ff26a4b0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java @@ -123,7 +123,7 @@ public static Observable retrieveAuthData(@NonNull PythonAuthResponse au }).subscribeOn(Schedulers.computation()); } - public static Observable restoreAccount(final AppleUserData appleUserData, final String anisetteServerUrl) { + public static Observable restoreAccount(final AppleUserData appleUserData) { return Observable.fromCallable(() -> { var data = AppCryptographyUtil.AppEncryptedData.fromFlattened(appleUserData.getData()); var account = new AppCryptographyUtil().decrypt(data, KEYSTORE_ALIAS_ACCOUNT); @@ -131,10 +131,11 @@ public static Observable restoreAccount(final AppleUserData var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); + // FindMy 0.9.x embeds the anisette provider state inside the account JSON + // (see AccountStateMapping.anisette), so we no longer pass a server URL here. var returned = module.callAttr( "getAccount", - new Kwarg("serializedAccountData", new String(account, StandardCharsets.UTF_8)), - new Kwarg("anisetteServerUrl", anisetteServerUrl) + new Kwarg("serializedAccountData", new String(account, StandardCharsets.UTF_8)) ); if (returned == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java index 19c0d580..84c98f1a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.graphics.Bitmap; +import android.content.res.Configuration; import android.util.Log; import android.view.View; @@ -57,7 +58,7 @@ public class AMapProvider implements IMapProvider { private Object aMap; // com.amap.api.maps.AMap private View mapView; // com.amap.api.maps.TextureMapView - private boolean isDarkMode = false; + private MapStyle currentMapStyle = MapStyle.FOLLOW_SYSTEM; @Override public void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback) { @@ -114,26 +115,38 @@ public void initialize(Activity activity, int containerViewId, OnMapReadyCallbac } @Override - public void setMapStyle(boolean darkMode) { - this.isDarkMode = darkMode; + public void setMapStyle(MapStyle mapStyle) { + this.currentMapStyle = mapStyle == null ? MapStyle.FOLLOW_SYSTEM : mapStyle; if (aMap == null) return; - + try { - // 高德地图设置地图类型和样式 - // com.amap.api.maps.AMap.MAP_TYPE_NORMAL = 1 (白天模式) - // com.amap.api.maps.AMap.MAP_TYPE_NIGHT = 4 (夜间模式) Class aMapClass = Class.forName("com.amap.api.maps.AMap"); java.lang.reflect.Method setMapTypeMethod = aMapClass.getMethod("setMapType", int.class); - - // 设置地图类型:夜间模式或普通模式 - int mapType = darkMode ? 4 : 1; // MAP_TYPE_NIGHT : MAP_TYPE_NORMAL + + int normalMapType = aMapClass.getField("MAP_TYPE_NORMAL").getInt(null); + int nightMapType = aMapClass.getField("MAP_TYPE_NIGHT").getInt(null); + int mapType = this.shouldUseDarkMap() ? nightMapType : normalMapType; setMapTypeMethod.invoke(aMap, mapType); - - Log.d(TAG, "Map style set to dark mode: " + darkMode); + + Log.d(TAG, "Map style set to " + this.currentMapStyle + ", dark=" + this.shouldUseDarkMap()); } catch (Exception e) { Log.e(TAG, "Failed to set map style", e); } } + + private boolean shouldUseDarkMap() { + if (this.currentMapStyle == MapStyle.DARK) { + return true; + } + if (this.currentMapStyle == MapStyle.LIGHT) { + return false; + } + if (this.activity == null) { + return false; + } + int currentNightMode = this.activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } @Override public String addMarker(MapMarker marker) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java index f2636bf1..b66f1546 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java @@ -16,7 +16,6 @@ import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; -import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.gms.maps.model.Polyline; @@ -38,7 +37,7 @@ public class GoogleMapProvider implements IMapProvider, OnMapReadyCallback { private Activity activity; private GoogleMap googleMap; private SupportMapFragment mapFragment; - private OnMapReadyCallback callback; + private IMapProvider.OnMapReadyCallback callback; // 存储标记点和路径线 private final Map markers = new HashMap<>(); @@ -47,9 +46,10 @@ public class GoogleMapProvider implements IMapProvider, OnMapReadyCallback { // 监听器 private OnMapClickListener onMapClickListener; private OnMarkerClickListener onMarkerClickListener; + private MapStyle currentMapStyle = MapStyle.FOLLOW_SYSTEM; @Override - public void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback) { + public void initialize(Activity activity, int containerViewId, IMapProvider.OnMapReadyCallback callback) { this.activity = activity; this.callback = callback; @@ -100,7 +100,9 @@ public void onMapReady(@NonNull GoogleMap googleMap) { return false; }); } - + + applyMapStyle(); + // 通知回调 if (callback != null) { callback.onMapReady(this); @@ -117,17 +119,28 @@ private String findMarkerId(Marker marker) { } @Override - public void setMapStyle(boolean darkMode) { + public void setMapStyle(MapStyle mapStyle) { + this.currentMapStyle = mapStyle == null ? MapStyle.FOLLOW_SYSTEM : mapStyle; + applyMapStyle(); + } + + private void applyMapStyle() { if (googleMap == null) return; - - if (darkMode) { - MapStyleOptions style = MapStyleOptions.loadRawResourceStyle( - activity.getApplicationContext(), - R.raw.map_dark_style - ); - googleMap.setMapStyle(style); - } else { - googleMap.setMapStyle(null); + + try { + Class mapColorSchemeClass = Class.forName("com.google.android.gms.maps.model.MapColorScheme"); + int colorScheme; + if (this.currentMapStyle == MapStyle.DARK) { + colorScheme = mapColorSchemeClass.getField("DARK").getInt(null); + } else if (this.currentMapStyle == MapStyle.LIGHT) { + colorScheme = mapColorSchemeClass.getField("LIGHT").getInt(null); + } else { + colorScheme = mapColorSchemeClass.getField("FOLLOW_SYSTEM").getInt(null); + } + + GoogleMap.class.getMethod("setMapColorScheme", int.class).invoke(googleMap, colorScheme); + } catch (Exception e) { + Log.w(TAG, "Failed to apply Google Maps color scheme, falling back to default map appearance", e); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java index 25a302d3..e1cf4856 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java @@ -23,9 +23,8 @@ public interface IMapProvider { /** * 设置地图样式 - * @param darkMode true为夜间模式,false为白天模式 */ - void setMapStyle(boolean darkMode); + void setMapStyle(MapStyle mapStyle); /** * 添加标记点 @@ -203,4 +202,10 @@ public float getZoom() { return zoom; } } + + enum MapStyle { + LIGHT, + DARK, + FOLLOW_SYSTEM + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java index f62f9393..50d22e7a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java @@ -32,6 +32,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import androidx.appcompat.app.AppCompatDelegate; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.service.web.GithubRawUtilityFilesService; @@ -62,6 +63,7 @@ public class SharedMainSettingsManager { private ArrayAdapter shownLocalesAdapter = null; private final Consumer onLanguageSelectedCallback; + private final Consumer onMapProviderSelectedCallback; private final Set urlOptions = new HashSet<>(); @@ -75,6 +77,7 @@ public class SharedMainSettingsManager { public SharedMainSettingsManager( @NonNull AppCompatActivity context, @NonNull Consumer onLanguageSelected, + @NonNull Consumer onMapProviderSelected, @NonNull Consumer onNewAnisetteUrlSelected, @NonNull GithubRawUtilityFilesService github, @NonNull UserSettings currentUserSettings, @@ -82,6 +85,7 @@ public SharedMainSettingsManager( ) { this.context = context; this.onLanguageSelectedCallback = onLanguageSelected; + this.onMapProviderSelectedCallback = onMapProviderSelected; this.onNewAnisetteUrlSelectedCallback = onNewAnisetteUrlSelected; this.github = github; this.currentUserSettings = currentUserSettings; @@ -127,11 +131,11 @@ public void setupLanguageSwitchField() { } private void setupCurrentLocalePretty() { - final String currentLocale = Locale.getDefault().getLanguage(); + final String currentLocale = this.getCurrentLocaleTag(); AppAutoCompleteTextView languageDropdown = this.context.findViewById(R.id.languageSelectDropdown); this.mappedLocales.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(currentLocale)) + .filter(kvp -> this.localeTagMatches(kvp.getValue(), currentLocale)) .findFirst() .map(Map.Entry::getKey) .ifPresent(option -> languageDropdown.setText(option, false)); @@ -144,6 +148,39 @@ private void setupCurrentLocalePretty() { handler.postDelayed(languageDropdown::clearFocus, 10); } + public void setupMapProviderField() { + AppAutoCompleteTextView mapProviderDropdown = this.context.findViewById(R.id.mapProviderSelectDropdown); + + String[] providerLabels = new String[] { + this.context.getString(R.string.map_provider_google), + this.context.getString(R.string.map_provider_amap) + }; + + mapProviderDropdown.setSimpleItems(providerLabels); + this.setupCurrentMapProviderPretty(); + + mapProviderDropdown.setOnItemClickListener((parent, view, position, id) -> { + final String selectedProvider = position == 1 ? "amap" : "google"; + final String selectedLabel = parent.getItemAtPosition(position).toString(); + mapProviderDropdown.setText(selectedLabel, false); + mapProviderDropdown.clearFocus(); + this.onMapProviderSelectedCallback.accept(selectedProvider); + }); + } + + private void setupCurrentMapProviderPretty() { + AppAutoCompleteTextView mapProviderDropdown = this.context.findViewById(R.id.mapProviderSelectDropdown); + if (mapProviderDropdown == null) { + return; + } + + final String provider = this.currentUserSettings.getMapProvider(); + final int labelRes = "amap".equals(provider) + ? R.string.map_provider_amap + : R.string.map_provider_google; + mapProviderDropdown.setText(this.context.getString(labelRes), false); + } + public void setupAnisetteServerUrlField() { TextInputLayout urlTextInputContainer = this.context.findViewById(R.id.anisetteServerUrlContainer); MaterialAutoCompleteTextView urlTextInput = this.context.findViewById(R.id.anisetteServerUrl); @@ -271,13 +308,38 @@ public void setAnisetteTextFieldError(final int stringId, Object... formatArgs) private String getPrettyLanguageName(final String languageId) { var res = this.context.getResources(); return res.getString(res.getIdentifier( - "lang_" + languageId, + LocaleConfigUtil.toLocaleLabelResourceName(languageId), "string", this.context.getPackageName())); } public void handleOnResume() { this.setupCurrentLocalePretty(); + this.setupCurrentMapProviderPretty(); + } + + private String getCurrentLocaleTag() { + String configuredTag = this.currentUserSettings.getLanguage(); + if (configuredTag != null && !configuredTag.isBlank()) { + return configuredTag; + } + + String appLocaleTags = AppCompatDelegate.getApplicationLocales().toLanguageTags(); + if (appLocaleTags != null && !appLocaleTags.isBlank()) { + return appLocaleTags.split(",")[0]; + } + + return Locale.getDefault().toLanguageTag(); + } + + private boolean localeTagMatches(String supportedLocaleTag, String currentLocaleTag) { + if (supportedLocaleTag == null || currentLocaleTag == null) { + return false; + } + if (supportedLocaleTag.equalsIgnoreCase(currentLocaleTag)) { + return true; + } + return currentLocaleTag.toLowerCase(Locale.ROOT).startsWith(supportedLocaleTag.toLowerCase(Locale.ROOT) + "-"); } public enum ANISETTE_TEST_STATUS { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java index cc378d81..f6aea10f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Optional; import dev.wander.android.opentagviewer.R; @@ -67,4 +68,13 @@ private static void handleStartTag(XmlResourceParser xml, List out) { out.add(name); } } + + public static String toLocaleLabelResourceName(String localeTag) { + if (localeTag == null || localeTag.isBlank()) { + return "lang_en"; + } + return "lang_" + localeTag + .replace('-', '_') + .toLowerCase(Locale.ROOT); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java index 3abdbc9c..ca27b537 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java @@ -417,10 +417,25 @@ private static List makeOwnedBeacons(final Map owne .importId(null) // TODO: fill on create Import .version(version) .content(kvp.getValue()) + // Eagerly convert plist → JSON for FindMy 0.9.x; nullable on failure + // (lazy backfill in BeaconRepository will retry on first fetch). + .accessoryJson(plistToAccessoryJsonOrNull(kvp.getValue())) .build()) .collect(Collectors.toList()); } + private static String plistToAccessoryJsonOrNull(final String plistXml) { + try { + var py = com.chaquo.python.Python.getInstance(); + var module = py.getModule("main"); + var result = module.callAttr("convertPlistToJson", plistXml); + return result == null ? null : result.toString(); + } catch (Exception e) { + Log.w(TAG, "convertPlistToJson at import time failed; will backfill lazily on first fetch", e); + return null; + } + } + private static List makeBeaconNamingRecords(final Map> beaconNamingRecords, final String version) { return beaconNamingRecords.entrySet().stream() .map(kvp -> BeaconNamingRecord.builder() diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 5b28c9f8..9699eea6 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -12,7 +12,7 @@ AppleAccount, LoginState, SmsSecondFactorMethod, - TrustedDeviceSecondFactorMethod + TrustedDeviceSecondFactorMethod, ) from findmy.reports.twofactor import ( SyncSecondFactorMethod @@ -175,17 +175,25 @@ def loginSync(email: str, password: str, anisetteServerUrl: str) -> dict: def exportToString(account: AppleAccount) -> str: - return json.dumps(account.export()) + """ + Replaces the old account.export() pattern. In FindMy 0.9.x, AppleAccount uses to_json/from_json. + The returned dict (AccountStateMapping) embeds the anisette provider state, so the + server URL no longer needs to be supplied at restore time. + """ + return json.dumps(account.to_json()) -def getAccount( - serializedAccountData: str, anisetteServerUrl: str) -> AppleAccount: +def getAccount(serializedAccountData: str, anisetteServerUrl: str = None) -> AppleAccount: + """ + Restore an AppleAccount via FindMy 0.9.x's `from_json`. The anisette provider is rebuilt + from the embedded state inside the JSON, so `anisetteServerUrl` is unused here. We accept + the parameter for now to keep the existing Java callsite compiling; it will be dropped + in Phase 2 when the Java bridge is updated. + """ try: data = json.loads(serializedAccountData) - anisette = RemoteAnisetteProvider(anisetteServerUrl) - acc = AppleAccount(anisette) - acc.restore(data) + acc = AppleAccount.from_json(data) print(f"Login State: {acc.login_state}") @@ -196,44 +204,115 @@ def getAccount( return None +def convertPlistToJson(plistXmlString: str) -> str: + """ + One-shot conversion from the legacy plist XML representation (still stored in + OwnedBeacon.content) to the JSON form that FindMy 0.9.x expects. + + Used in two places (called from Java): + - During .zip import: convert once and store alongside the raw plist + - As a lazy backfill: when reading an OwnedBeacon row that predates the upgrade + + Returns None on failure so Java can decide how to recover. + """ + try: + fp = BytesIO(plistXmlString.encode('utf-8')) + accessory = FindMyAccessory.from_plist(fp) + return json.dumps(accessory.to_json()) + except Exception: + print(f"convertPlistToJson failed: {traceback.format_exc()}") + return None + + +def _filterReportsByTimeRange(reports, startMs, endMs): + """ + Apple's network only ever returns ~7 days of history and 0.9.x removed the + user-facing time-range parameters from fetch_location_history. We filter + here so the Java side keeps the same time-window semantics it had before. + """ + out = [] + for r in reports: + ts_ms = _toUnixEpochMs(r.timestamp) + if ts_ms is None: + continue + if startMs is not None and ts_ms < startMs: + continue + if endMs is not None and ts_ms > endMs: + continue + out.append(r) + return out + + +def _serializeReports(reports): + """ + Map FindMy 0.9.x LocationReport objects to the dict shape Java's mapResults expects. + Note: `published_at` and `description` no longer exist on LocationReport in 0.9.x; we + fall back to `timestamp` and an empty string respectively. Java's BeaconLocationReport + can absorb that without changes. + """ + items = [] + for report in sorted(reports): + items.append({ + "publishedAt": _toUnixEpochMs(report.timestamp), + "description": getattr(report, "description", "") or "", + "timestamp": _toUnixEpochMs(report.timestamp), + "confidence": report.confidence, + "latitude": report.latitude, + "longitude": report.longitude, + "horizontalAccuracy": report.horizontal_accuracy, + "status": report.status + }) + return items + + def getLastReports( account: AppleAccount, - idToPList, + idToAccessoryData, hoursBack: int) -> dict: - # JAVA typing: see https://chaquo.com/chaquopy/doc/current/python.html - # especially this: https://chaquo.com/chaquopy/doc/current/python.html#classes + """ + Fetch the most recent reports for each beacon over the requested time window. + + `idToAccessoryData` is a List from Java where each element exposes + getBeaconId() / getAccessoryJson() (the persisted FindMyAccessory JSON). + + Each entry in the result dict carries: + - "reports": list of report dicts (same shape as before) + - "updatedAccessoryJson": JSON string of the accessory AFTER fetch — Java must + write this back to OwnedBeacon.accessory_json so the rolling key alignment + survives across calls (this is the issue #30 fix). + """ try: res = {} - num_items = idToPList.size() - print(f"num_items is {num_items}") + num_items = idToAccessoryData.size() + print(f"getLastReports: num_items={num_items}, hoursBack={hoursBack}") + + now_ms = int(datetime.now(tz=timezone.utc).timestamp() * 1000) + start_ms = now_ms - (hoursBack * 60 * 60 * 1000) for i in range(0, num_items): - pair = idToPList.get(i) - beaconId = pair.first - plistContent = pair.second + req = idToAccessoryData.get(i) + beaconId = req.getBeaconId() + accessoryJson = req.getAccessoryJson() print(f"Fetching report for {beaconId} for the last {hoursBack} hours...") - fp = BytesIO(plistContent.encode('utf-8')) - airtag = FindMyAccessory.from_plist(fp) - - reports = account.fetch_last_reports(airtag, hoursBack) - print(f"Got {len(reports)} reports for {beaconId}") - - items = [] - for report in sorted(reports): - items.append({ - "publishedAt": _toUnixEpochMs(report.published_at), - "description": report.description, - "timestamp": _toUnixEpochMs(report.timestamp), - "confidence": report.confidence, - "latitude": report.latitude, - "longitude": report.longitude, - "horizontalAccuracy": report.horizontal_accuracy, - "status": report.status - }) - - res[beaconId] = items + + airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + + reports = account.fetch_location_history(airtag) + if reports is None: + reports = [] + print(f"Got {len(reports)} raw reports for {beaconId}") + + filtered = _filterReportsByTimeRange(reports, start_ms, now_ms) + print(f" -> {len(filtered)} reports after filtering to last {hoursBack}h") + + updated_accessory_json = json.dumps(airtag.to_json()) + + res[beaconId] = { + "reports": _serializeReports(filtered), + "updatedAccessoryJson": updated_accessory_json, + } return res @@ -245,51 +324,45 @@ def getLastReports( def getReports( account: AppleAccount, - idToPList, + idToAccessoryData, unixStartMs: int, unixEndMs: int) -> dict: - # JAVA typing: see https://chaquo.com/chaquopy/doc/current/python.html - # especially this: https://chaquo.com/chaquopy/doc/current/python.html#classes + """ + Time-range variant. Apple's network only retains ~7 days of history; ranges further + back than that will return empty (the local Room cache is already the canonical store + for older history via DailyHistoryFetchRecord). + + Same input/output shape as `getLastReports`. + """ try: res = {} - num_items = idToPList.size() - print(f"num_items is {num_items}") + num_items = idToAccessoryData.size() + print(f"getReports: num_items={num_items}, range=[{unixStartMs}, {unixEndMs}]") for i in range(0, num_items): - pair = idToPList.get(i) - beaconId = pair.first - plistContent = pair.second + req = idToAccessoryData.get(i) + beaconId = req.getBeaconId() + accessoryJson = req.getAccessoryJson() print(f"Fetching report for {beaconId} in time range {unixStartMs}-{unixEndMs}...") - fp = BytesIO(plistContent.encode('utf-8')) - airtag = FindMyAccessory.from_plist(fp) - - start: datetime = datetime.fromtimestamp( - unixStartMs/1000, - tz=timezone.utc - ) - end: datetime = datetime.fromtimestamp( - unixEndMs/1000, - tz=timezone.utc - ) - reports = account.fetch_reports(airtag, start, end) - print(f"Got {len(reports)} reports for {beaconId} for time range {unixStartMs}-{unixEndMs}") - - items = [] - for report in sorted(reports): - items.append({ - "publishedAt": _toUnixEpochMs(report.published_at), - "description": report.description, - "timestamp": _toUnixEpochMs(report.timestamp), - "confidence": report.confidence, - "latitude": report.latitude, - "longitude": report.longitude, - "horizontalAccuracy": report.horizontal_accuracy, - "status": report.status - }) - - res[beaconId] = items + + airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + + reports = account.fetch_location_history(airtag) + if reports is None: + reports = [] + print(f"Got {len(reports)} raw reports for {beaconId}") + + filtered = _filterReportsByTimeRange(reports, unixStartMs, unixEndMs) + print(f" -> {len(filtered)} reports after filtering to requested range") + + updated_accessory_json = json.dumps(airtag.to_json()) + + res[beaconId] = { + "reports": _serializeReports(filtered), + "updatedAccessoryJson": updated_accessory_json, + } return res diff --git a/app/src/main/res/layout/activity_maps.xml b/app/src/main/res/layout/activity_maps.xml index 3d62e05d..699fa36d 100644 --- a/app/src/main/res/layout/activity_maps.xml +++ b/app/src/main/res/layout/activity_maps.xml @@ -19,7 +19,7 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/main_app_settings.xml b/app/src/main/res/layout/main_app_settings.xml index cb31f95f..d6da9cdc 100644 --- a/app/src/main/res/layout/main_app_settings.xml +++ b/app/src/main/res/layout/main_app_settings.xml @@ -27,6 +27,27 @@ + + + + + + + + + @@ -92,4 +113,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/map_provider_input.xml b/app/src/main/res/layout/map_provider_input.xml new file mode 100644 index 00000000..0fcc5c29 --- /dev/null +++ b/app/src/main/res/layout/map_provider_input.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/maps_tag_card.xml b/app/src/main/res/layout/maps_tag_card.xml index cdf36d2d..7370b5b4 100644 --- a/app/src/main/res/layout/maps_tag_card.xml +++ b/app/src/main/res/layout/maps_tag_card.xml @@ -24,18 +24,19 @@ + android:paddingBottom="8dp"> + android:layout_height="72dp" + android:layout_gravity="top" + android:layout_weight="0"> @@ -104,11 +105,11 @@ @@ -130,19 +131,22 @@ @@ -183,9 +187,10 @@ @@ -193,12 +198,14 @@ @@ -239,19 +246,22 @@ @@ -357,4 +371,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..6a4ecf88 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,102 @@ + + + Android Studio + 导航标题 + 设置 + 主页 + 图库 + 轮播 + 设置 + 导入 + 关于 + 最后更新:%1$s + 位置记录 + 刷新 + 响铃 + 更多 + 版本 %1$s + 由 Shane B. 开发的免费开源软件 + Github + 开发者网站 + Anisette 服务器 URL + 输入 Anisette 服务器地址 + 这不是有效的 URL + 语言 + zh-CN + English + 荷兰语 (Nederlands) + 德语 (Deutsch) + 法语 (Français) + 俄语 (Русский) + 简体中文 + 繁體中文 + 无法连接到位于 %1$s 的 Anisette 服务器! + 登录失败:%1$s + 邮箱或手机号 + 密码 + Apple 账号 + 登录 + 欢迎! + 短信 (%1$s) + 受信任设备 + 选择你的服务器 + 选择双重验证方式 + 正在登录... + 请输入发送到你的 Apple 设备上的验证码。 + 请输入发送到手机号 %1$s 的验证码 + 正在请求验证码... + 双重验证 + 当前登录账号 + 退出登录 + 启用定位权限? + 拒绝 + 确认 + 你的位置信息仅用于在地图上显示当前位置,不会离开设备。 + 我的设备 + 暂无已知位置 + 今天 + 昨天 + %1$d 个数据点 + 深色主题 + 浅色主题 + 跟随系统 + 主题 + 测试 + 取消 + 历史记录:%1$s + 重试 + 获取历史记录时出错 + 确定要退出登录吗? + 启用调试数据 + 设备名称 + 导出者 + 导出时间 + 类型 + 未知 + 导入时间 + 正在为 %1$d 个新导入设备加载位置数据... + 导入新设备时出错,请重启应用后重试。 + 双重验证时发生错误 + 设置已更新 + 更新设置时出错 + 移除设备 + 确定要移除此设备吗?移除后需要重新导入才能恢复。 + 确认 + 更改 Anisette URL 后需要重新登录到 Anisette 服务器。确认后将跳转到登录页。 + 设备图标 + 设备设置 + 设备信息 + 已复制“%1$s”到剪贴板 + 应用 Wiki + 双重验证失败:%1$s + 双重验证失败:%1$s \n可以尝试更换 Anisette 服务器或 Apple 账号。 + 发生错误,请重新提交双重验证代码 + 请求验证码失败,请重试。 + 韩语 (한국어) + 日语 (日本語) + 地图提供商 + Google 地图 + 高德地图 (AMap) + FindMy 库已更新,请重新登录。 + 请先退出登录,再更换 Anisette 服务器。 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000..ccc18d4e --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,102 @@ + + + Android Studio + 導覽標題 + 設定 + 首頁 + 圖庫 + 輪播 + 設定 + 匯入 + 關於 + 最後更新:%1$s + 位置記錄 + 重新整理 + 響鈴 + 更多 + 版本 %1$s + 由 Shane B. 開發的免費開源軟體 + Github + 開發者網站 + Anisette 伺服器 URL + 輸入 Anisette 伺服器位址 + 這不是有效的 URL + 語言 + zh-TW + English + 荷蘭語 (Nederlands) + 德語 (Deutsch) + 法語 (Français) + 俄語 (Русский) + 簡體中文 + 繁體中文 + 無法連線到位於 %1$s 的 Anisette 伺服器! + 登入失敗:%1$s + 電子郵件或手機號碼 + 密碼 + Apple 帳號 + 登入 + 歡迎! + 簡訊 (%1$s) + 受信任裝置 + 選擇你的伺服器 + 選擇雙重驗證方式 + 正在登入... + 請輸入發送到你的 Apple 裝置上的驗證碼。 + 請輸入發送到手機號碼 %1$s 的驗證碼 + 正在請求驗證碼... + 雙重驗證 + 目前登入帳號 + 登出 + 啟用定位權限? + 拒絕 + 確認 + 你的位置信息僅用於在地圖上顯示目前位置,不會離開裝置。 + 我的裝置 + 暫無已知位置 + 今天 + 昨天 + %1$d 個資料點 + 深色主題 + 淺色主題 + 跟隨系統 + 主題 + 測試 + 取消 + 歷史記錄:%1$s + 重試 + 取得歷史記錄時發生錯誤 + 確定要登出嗎? + 啟用除錯資料 + 裝置名稱 + 匯出者 + 匯出時間 + 類型 + 未知 + 匯入時間 + 正在為 %1$d 個新匯入裝置載入位置資料... + 匯入新裝置時發生錯誤,請重新啟動應用後再試。 + 雙重驗證時發生錯誤 + 設定已更新 + 更新設定時發生錯誤 + 移除裝置 + 確定要移除此裝置嗎?移除後需要重新匯入才能恢復。 + 確認 + 更改 Anisette URL 後需要重新登入 Anisette 伺服器。確認後將跳轉到登入頁。 + 裝置圖示 + 裝置設定 + 裝置資訊 + 已複製「%1$s」到剪貼簿 + 應用 Wiki + 雙重驗證失敗:%1$s + 雙重驗證失敗:%1$s \n可以嘗試更換 Anisette 伺服器或 Apple 帳號。 + 發生錯誤,請重新提交雙重驗證碼 + 請求驗證碼失敗,請重試。 + 韓語 (한국어) + 日語 (日本語) + 地圖提供商 + Google 地圖 + 高德地圖 (AMap) + FindMy 函式庫已更新,請重新登入。 + 請先登出,再更換 Anisette 伺服器。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 520b5a5e..ae616d76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,8 @@ Deutsch Français Русский + 简体中文 + 繁體中文 Anisette server at %1$s could not be reached! Welcome ! Login failed: %1$s @@ -128,4 +130,6 @@ Map Provider Google Maps 高德地图 (AMap) - \ No newline at end of file + Please sign in again — the FindMy library has been updated. + Sign out first to change your Anisette server. + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 644a0d68..1272b7ad 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -7,4 +7,6 @@ - \ No newline at end of file + + + diff --git a/scripts/rebuild_unicorn_stub_wheel.py b/scripts/rebuild_unicorn_stub_wheel.py new file mode 100644 index 00000000..5515e38e --- /dev/null +++ b/scripts/rebuild_unicorn_stub_wheel.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import base64 +import csv +import hashlib +from io import StringIO +from pathlib import Path +import zipfile + + +WHEEL_PATH = Path("app/libs/unicorn-2.1.1-py3-none-any.whl") + +FILES = { + "unicorn/__init__.py": """# Stub unicorn package — satisfies the anisette dependency without native code. +# OpenTagViewer only uses RemoteAnisetteProvider; the local Anisette emulator +# (which is the only thing that instantiates Uc) is never reached at runtime. + +UC_ARCH_ARM = 1 +UC_ARCH_ARM64 = 2 +UC_ARCH_X86 = 4 + +UC_MODE_ARM = 0 +UC_MODE_32 = 1 << 2 +UC_MODE_64 = 1 << 3 + +UC_HOOK_CODE = 1 << 1 +UC_HOOK_BLOCK = 1 << 3 +UC_HOOK_MEM_READ_UNMAPPED = 1 << 4 +UC_HOOK_MEM_WRITE_UNMAPPED = 1 << 5 +UC_HOOK_MEM_FETCH_UNMAPPED = 1 << 6 + +UC_MEM_WRITE_UNMAPPED = 19 +UC_MEM_FETCH_UNMAPPED = 20 + +from .unicorn import Uc + +__all__ = [ + "UC_ARCH_ARM", "UC_ARCH_ARM64", "UC_ARCH_X86", + "UC_MODE_ARM", "UC_MODE_32", "UC_MODE_64", + "UC_HOOK_CODE", "UC_HOOK_BLOCK", + "UC_HOOK_MEM_READ_UNMAPPED", "UC_HOOK_MEM_WRITE_UNMAPPED", "UC_HOOK_MEM_FETCH_UNMAPPED", + "UC_MEM_WRITE_UNMAPPED", "UC_MEM_FETCH_UNMAPPED", + "Uc", +] +""", + "unicorn/arm64_const.py": """UC_ARM64_REG_FP = 1 +UC_ARM64_REG_LR = 2 +UC_ARM64_REG_SP = 3 +UC_ARM64_REG_W13 = 163 +UC_ARM64_REG_W14 = 164 +UC_ARM64_REG_W15 = 165 +UC_ARM64_REG_X0 = 190 +UC_ARM64_REG_X1 = 191 +UC_ARM64_REG_X2 = 192 +""", + "unicorn/unicorn.py": """class Uc: + def __init__(self, arch, mode): + raise NotImplementedError( + "unicorn stub: local Anisette is not supported on Android. " + "Use RemoteAnisetteProvider instead." + ) + + def mem_map(self, address, size, perms=7): + raise NotImplementedError + + def mem_write(self, address, data): + raise NotImplementedError + + def mem_read(self, address, length): + raise NotImplementedError + + def reg_write(self, reg_id, value): + raise NotImplementedError + + def reg_read(self, reg_id): + raise NotImplementedError + + def hook_add(self, hook_type, callback, user_data=None, begin=1, end=0, arg1=0): + raise NotImplementedError + + def emu_start(self, begin, until, timeout=0, count=0): + raise NotImplementedError + + def emu_stop(self): + raise NotImplementedError +""", + "unicorn-2.1.1.dist-info/METADATA": """Metadata-Version: 2.1 +Name: unicorn +Version: 2.1.1 +Summary: Unicorn CPU emulator engine (Android stub - no native code) +License: GPL-2 +""", + "unicorn-2.1.1.dist-info/WHEEL": """Wheel-Version: 1.0 +Generator: rebuild_unicorn_stub_wheel.py +Root-Is-Purelib: true +Tag: py3-none-any +""", +} + + +def record_line(path: str, data: bytes) -> tuple[str, str, str]: + digest = hashlib.sha256(data).digest() + digest_b64 = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return path, f"sha256={digest_b64}", str(len(data)) + + +def build_record(rows: list[tuple[str, str, str]]) -> bytes: + output = StringIO() + writer = csv.writer(output, lineterminator="\n") + for row in rows: + writer.writerow(row) + writer.writerow(("unicorn-2.1.1.dist-info/RECORD", "", "")) + return output.getvalue().encode("utf-8") + + +def main() -> int: + WHEEL_PATH.parent.mkdir(parents=True, exist_ok=True) + + rows: list[tuple[str, str, str]] = [] + with zipfile.ZipFile(WHEEL_PATH, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path, text in FILES.items(): + data = text.encode("utf-8") + zf.writestr(path, data) + rows.append(record_line(path, data)) + + record_data = build_record(rows) + zf.writestr("unicorn-2.1.1.dist-info/RECORD", record_data) + + print(f"Rebuilt {WHEEL_PATH}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_stub_wheels.py b/scripts/validate_stub_wheels.py new file mode 100644 index 00000000..3a634765 --- /dev/null +++ b/scripts/validate_stub_wheels.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import argparse +import sys +import zipfile +from pathlib import Path + + +BOM = b"\xef\xbb\xbf" + + +def validate_wheel(path: Path) -> list[str]: + errors: list[str] = [] + + if not path.exists(): + return [f"{path}: file not found"] + + try: + with zipfile.ZipFile(path) as zf: + dist_info_dirs = sorted( + { + name.rsplit("/", 1)[0] + for name in zf.namelist() + if name.endswith(".dist-info/WHEEL") + } + ) + if len(dist_info_dirs) != 1: + return [f"{path}: expected exactly one .dist-info/WHEEL entry, found {len(dist_info_dirs)}"] + + dist_info_dir = dist_info_dirs[0] + wheel_bytes = zf.read(f"{dist_info_dir}/WHEEL") + if wheel_bytes.startswith(BOM): + errors.append(f"{path}: {dist_info_dir}/WHEEL starts with UTF-8 BOM") + + wheel_text = wheel_bytes.decode("utf-8-sig") + if not any(line.startswith("Wheel-Version:") for line in wheel_text.splitlines()): + errors.append(f"{path}: {dist_info_dir}/WHEEL is missing Wheel-Version") + + metadata_bytes = zf.read(f"{dist_info_dir}/METADATA") + if metadata_bytes.startswith(BOM): + errors.append(f"{path}: {dist_info_dir}/METADATA starts with UTF-8 BOM") + + record_bytes = zf.read(f"{dist_info_dir}/RECORD") + if record_bytes.startswith(BOM): + errors.append(f"{path}: {dist_info_dir}/RECORD starts with UTF-8 BOM") + except zipfile.BadZipFile: + return [f"{path}: invalid zip archive"] + except KeyError as exc: + return [f"{path}: missing required wheel entry: {exc}"] + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate local stub wheels checked into the repository." + ) + parser.add_argument( + "wheels", + nargs="+", + type=Path, + help="Wheel files to validate.", + ) + args = parser.parse_args() + + all_errors: list[str] = [] + for wheel in args.wheels: + all_errors.extend(validate_wheel(wheel)) + + if all_errors: + for error in all_errors: + print(error, file=sys.stderr) + return 1 + + for wheel in args.wheels: + print(f"{wheel}: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 376d0ab497994e43d74e5bf3355d1b65e67883dc Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 13:20:33 +0200 Subject: [PATCH 03/50] Export Room schemas and add v1 to v2 migration tests The migration itself came from Gare in the previous commit; this adds the tooling and coverage that proves it works and keeps it working. Room was building with exportSchema unset, which emitted a warning and left no schema JSON to diff. Now that there is more than one schema version - and a macless export path may well introduce a third - schema changes should show up as a reviewable diff. The exported v1/v2 JSONs are also what MigrationTestHelper needs to build an old database to migrate from. The v1 schema had never been exported, so it was regenerated by compiling against main's pre-existing entity shape. Diffing the two confirms the only change is the nullable accessory_json column, which matches the migration's ALTER TABLE exactly. The tests cover what a user would actually lose if this went wrong: beacon rows, their plist content (which the lazy backfill re-reads), soft-delete state, and location history. Also covers the empty-database case, since a fresh install that has never imported still has a v1 database to upgrade. MIGRATION_1_2 is made public so the test APK can reference it reliably. Co-Authored-By: Gare Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 18 + .../1.json | 405 +++++++++++++++++ .../2.json | 411 ++++++++++++++++++ .../OpenTagViewerDatabaseMigrationTest.java | 206 +++++++++ .../db/room/OpenTagViewerDatabase.java | 2 +- 5 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ca15a0b..2a270a87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,23 @@ android { ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + + // Export the Room schema as JSON on every build. These are committed (see + // app/schemas/) so schema changes show up as a reviewable diff, and so + // MigrationTestHelper can build an old database to migrate from. + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + } + } + + // Makes the exported schemas readable by instrumented tests at runtime, which is + // how MigrationTestHelper creates a v1 database to run MIGRATION_1_2 against. + sourceSets { + getByName("androidTest") { + assets.srcDirs(files("$projectDir/schemas")) + } } signingConfigs { @@ -150,6 +167,7 @@ dependencies { androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.android.room.testing) annotationProcessor(libs.projectlombok) annotationProcessor(libs.android.room.compiler) diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json new file mode 100644 index 00000000..bc227b59 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json @@ -0,0 +1,405 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "4059c5ff95bec440e4a41a8b6f45e8c3", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4059c5ff95bec440e4a41a8b6f45e8c3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json new file mode 100644 index 00000000..e8b48415 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json @@ -0,0 +1,411 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "bc82a62bd44adcb4abfdfc6d038db3cb", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `accessory_json` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc82a62bd44adcb4abfdfc6d038db3cb')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java new file mode 100644 index 00000000..e5447c2a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java @@ -0,0 +1,206 @@ +package dev.wander.android.opentagviewer.db.room; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.room.testing.MigrationTestHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +/** + * Regression tests for the v1 → v2 database upgrade. + *
+ * This exists because the failure mode is invisible on a fresh install: a version bump + * without a registered {@link androidx.room.migration.Migration} only throws for users + * who already have a database, and it throws at first DB access rather than at build + * time. Every existing install would have crashed with + * {@code IllegalStateException: A migration from 1 to 2 was required but not found}. + *
+ * The migration must also be non-destructive: falling back to a destructive migration + * here would wipe imported beacons and location history, forcing users through the + * macOS export wizard again — the exact thing this app exists to avoid. + */ +@RunWith(AndroidJUnit4.class) +public class OpenTagViewerDatabaseMigrationTest { + private static final String TEST_DB = "migration-test-db"; + + private static final String BEACON_ID = "ABCDEF01-2345-6789-ABCD-EF0123456789"; + private static final String BEACON_PLIST = ""; + + @Rule + public MigrationTestHelper helper = new MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + OpenTagViewerDatabase.class + ); + + /** + * The core guarantee: an existing v1 database survives the upgrade with its rows and + * column values intact, and gains a NULL {@code accessory_json}. + */ + @Test + public void migrate1To2_preservesExistingBeacons() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, /* validateDroppedTables= */ true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query( + "SELECT id, import_id, content, version, is_removed, accessory_json" + + " FROM OwnedBeacons WHERE id = ?", new Object[]{BEACON_ID})) { + + assertTrue("beacon row did not survive the migration", cursor.moveToFirst()); + assertEquals(BEACON_ID, cursor.getString(0)); + assertEquals(1L, cursor.getLong(1)); + assertEquals("plist content was altered by the migration", BEACON_PLIST, cursor.getString(2)); + assertEquals("1.0", cursor.getString(3)); + assertEquals(0, cursor.getInt(4)); + + // Pre-existing rows have no accessory JSON yet. BeaconRepository#toAccessoryRequests + // backfills it from the retained plist on the next fetch. + assertTrue("accessory_json should start NULL for migrated rows", cursor.isNull(5)); + assertFalse("expected exactly one row for this beacon", cursor.moveToNext()); + } + } + + /** + * The plist in {@code content} is what the lazy backfill re-converts from, so the + * migration must not drop it once {@code accessory_json} exists. Guards against a + * future "clean up the now-redundant column" change. + */ + @Test + public void migrate1To2_retainsPlistContentColumn() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT content FROM OwnedBeacons WHERE id = ?", new Object[]{BEACON_ID})) { + assertTrue(cursor.moveToFirst()); + assertNotNull("content must survive - the backfill re-reads it", cursor.getString(0)); + } + } + + /** + * Multiple beacons across imports, including a soft-deleted one, all survive. Row + * count is the thing users would notice going wrong. + */ + @Test + public void migrate1To2_preservesAllRowsIncludingRemoved() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertImport(db, 2L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + insertOwnedBeaconV1(db, "beacon-c", 2L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons were lost during the migration", 3, cursor.getInt(0)); + } + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons WHERE is_removed = 1")) { + assertTrue(cursor.moveToFirst()); + assertEquals("soft-delete state was not preserved", 1, cursor.getInt(0)); + } + } + + /** + * An empty v1 database (installed but never imported) must also upgrade cleanly. + */ + @Test + public void migrate1To2_handlesEmptyDatabase() throws IOException { + helper.createDatabase(TEST_DB, 1).close(); + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals(0, cursor.getInt(0)); + } + } + + /** + * Location history is the expensive-to-rebuild data, and it is not what the migration + * touches — so it is exactly what a careless destructive fallback would silently eat. + */ + @Test + public void migrate1To2_preservesLocationHistory() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + insertLocationReport(db, "hash-1", BEACON_ID, 1700000000000L); + insertLocationReport(db, "hash-2", BEACON_ID, 1700000600000L); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query( + "SELECT COUNT(*) FROM LocationReport WHERE beacon_id = ?", new Object[]{BEACON_ID})) { + assertTrue(cursor.moveToFirst()); + assertEquals("location history was lost during the migration", 2, cursor.getInt(0)); + } + } + + private static void insertImport(SupportSQLiteDatabase db, long id) { + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("version", "1.0"); + values.put("imported_at", 1700000000000L); + values.put("exported_at", 1699999999000L); + values.put("source_user", "someone@example.com"); + values.put("via", "test"); + db.insert("Import", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } + + private static void insertOwnedBeaconV1( + SupportSQLiteDatabase db, String id, long importId, String content, boolean isRemoved) { + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("import_id", importId); + values.put("content", content); + values.put("version", "1.0"); + values.put("is_removed", isRemoved ? 1 : 0); + db.insert("OwnedBeacons", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } + + private static void insertLocationReport( + SupportSQLiteDatabase db, String hashId, String beaconId, long timestamp) { + ContentValues values = new ContentValues(); + values.put("hash_id", hashId); + values.put("beacon_id", beaconId); + values.put("published_at", timestamp); + values.put("description", "test report"); + values.put("timestamp", timestamp); + values.put("confidence", 2); + values.put("latitude", 52.379189); + values.put("longitude", 4.899431); + values.put("horizontal_accuracy", 10); + values.put("status", 0); + values.put("last_update", timestamp); + db.insert("LocationReport", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java index c5b7d3f9..22fc02d1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java @@ -41,7 +41,7 @@ public abstract class OpenTagViewerDatabase extends RoomDatabase { * stateful FindMyAccessory persistence (issue #30 fix). Pure additive ALTER — * existing rows survive with NULL and are lazily backfilled on first fetch. */ - static final Migration MIGRATION_1_2 = new Migration(1, 2) { + public static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(@NonNull SupportSQLiteDatabase db) { db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN accessory_json TEXT"); From ebec689114cd6789009725d478e59ede9e490fa7 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 13:39:43 +0200 Subject: [PATCH 04/50] Add offline harness to measure first-fetch key-search cost FindMyAccessory.from_plist() takes an optional key_alignment_plist supplying lastIndexObservationDate / lastIndexObserved. The export wizard only captures OwnedBeacons/ and BeaconNamingRecord/, so it is always absent and alignment falls back to paired_at with index 0. Key lookup then spans get_min_index(start)..get_max_index(end), and the AirTag rotation interval is 15 minutes, so the first fetch after migrating to 0.9.x searches from the pairing date to now. Measured against synthetic plists: a tag paired 30 days ago needs ~2.9k keys, one paired 2 years ago needs ~70k. update_alignment() narrows the window after the first success and storeFetchResult persists it, so this is a one-time cost per beacon rather than an ongoing one. But it is the "spam queries to Apple and get the account flagged" risk from issue #30, and it scales with tag age, so it needs measuring against real exports before release. The script is offline and read-only - it never contacts Apple and needs no account. Identifiers are not printed unless --show-identifiers is passed, so the output is safe to paste into an issue. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/measure_key_alignment_cost.py | 167 ++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 scripts/measure_key_alignment_cost.py diff --git a/scripts/measure_key_alignment_cost.py b/scripts/measure_key_alignment_cost.py new file mode 100644 index 00000000..ddc6e19a --- /dev/null +++ b/scripts/measure_key_alignment_cost.py @@ -0,0 +1,167 @@ +"""Measure the first-fetch key-search cost for an exported beacon plist. + +Why this exists +--------------- +FindMy.py's ``FindMyAccessory.from_plist()`` accepts an optional second plist +(``key_alignment_plist``) carrying ``lastIndexObservationDate`` / +``lastIndexObserved``. OpenTagViewer's export wizard only captures +``OwnedBeacons/`` and ``BeaconNamingRecord/``, so that argument is always None +and the accessory falls back to:: + + alignment_date = paired_at + alignment_index = 0 + +Key lookup then spans ``range(get_min_index(start), get_max_index(end) + 1)``. +For a tag paired long ago that is a large number of keys on the *first* fetch +after migrating to 0.9.x - which is exactly the "spam queries to Apple and get +your account flagged" risk raised in issue #30. + +After the first successful fetch, ``update_alignment()`` narrows the window and +BeaconRepository#storeFetchResult persists it, so this is a one-time cost per +beacon. This script quantifies that one-time cost so we can decide whether the +first fetch needs to be staged. + +This is read-only and offline: it never contacts Apple and never needs an +account. Nothing it reads is written anywhere. + +Usage +----- + python scripts/measure_key_alignment_cost.py [--hours-back 24] + +Accepts a single ``.plist`` or a directory (scanned recursively). +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +try: + from findmy import FindMyAccessory +except ImportError: + print( + "FindMy is not installed in this interpreter.\n" + " python -m venv .venv && .venv/bin/pip install 'FindMy==0.9.8'", + file=sys.stderr, + ) + raise SystemExit(2) from None + + +# Rough guide only: what counts as "fine" vs "worth staging" for a single first fetch. +WARN_KEY_COUNT = 5_000 +BAD_KEY_COUNT = 25_000 + + +def _describe(accessory: FindMyAccessory, hours_back: int) -> dict: + now = datetime.now(tz=timezone.utc) + start = now - timedelta(hours=hours_back) + + min_index = accessory.get_min_index(start) + max_index = accessory.get_max_index(now) + key_count = max(0, max_index - min_index + 1) + + paired_at = accessory.paired_at + if paired_at.tzinfo is None: + paired_at = paired_at.replace(tzinfo=timezone.utc) + age_days = (now - paired_at).days + + return { + "identifier": getattr(accessory, "identifier", "?"), + "model": getattr(accessory, "model", "?"), + "paired_at": paired_at, + "age_days": age_days, + "min_index": min_index, + "max_index": max_index, + "key_count": key_count, + } + + +def _verdict(key_count: int) -> str: + if key_count >= BAD_KEY_COUNT: + return "HIGH - stage the first fetch" + if key_count >= WARN_KEY_COUNT: + return "elevated" + return "fine" + + +def _round_trips_cleanly(accessory: FindMyAccessory) -> bool: + """The migration path is plist -> to_json -> stored -> from_json, so prove it survives.""" + try: + restored = FindMyAccessory.from_json(accessory.to_json()) + except Exception as exc: # noqa: BLE001 - reporting, not handling + print(f" ! to_json/from_json round trip FAILED: {exc}") + return False + + same = ( + getattr(restored, "identifier", None) == getattr(accessory, "identifier", None) + and restored.paired_at == accessory.paired_at + ) + if not same: + print(" ! round trip lost or altered identity fields") + return same + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("path", type=Path, help="a .plist file, or a directory to scan recursively") + parser.add_argument("--hours-back", type=int, default=24, help="fetch window to simulate (default: 24)") + parser.add_argument( + "--show-identifiers", + action="store_true", + help="print accessory UUIDs and filenames. Off by default: the key counts are the " + "point, and the identifiers are not needed to interpret them.", + ) + args = parser.parse_args(argv[1:]) + + if not args.path.exists(): + print(f"no such path: {args.path}", file=sys.stderr) + return 2 + + if args.path.is_dir(): + plists = sorted(p for p in args.path.rglob("*.plist") if p.is_file()) + else: + plists = [args.path] + + if not plists: + print(f"no .plist files found under {args.path}", file=sys.stderr) + return 2 + + print(f"Simulating a {args.hours_back}h fetch for {len(plists)} beacon(s), " + f"with no key alignment record (the export wizard does not capture one).\n") + + failures = 0 + worst = 0 + for position, plist in enumerate(plists, start=1): + label = plist.name if args.show_identifiers else f"beacon #{position}" + + try: + accessory = FindMyAccessory.from_plist(plist) + except Exception as exc: # noqa: BLE001 - we want to know which one failed + print(f" {label}: FAILED to parse - {exc}") + failures += 1 + continue + + info = _describe(accessory, args.hours_back) + worst = max(worst, info["key_count"]) + + print(f" {label}") + identity = f" identifier {info['identifier']}" if args.show_identifiers else "" + print(f" model {info['model']}{identity}") + print(f" paired {info['paired_at']:%Y-%m-%d} ({info['age_days']} days ago)") + print(f" index range {info['min_index']} .. {info['max_index']}") + print(f" keys first fetch {info['key_count']:,} [{_verdict(info['key_count'])}]") + _round_trips_cleanly(accessory) + print() + + print(f"Worst-case first fetch: {worst:,} keys [{_verdict(worst)}]") + if failures: + print(f"{failures} plist(s) could not be parsed by FindMy 0.9.x - these would be " + f"silently skipped by the backfill.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From f03abdb8d1158c46cbacb28bafdae3c4d69699a5 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 13:42:45 +0200 Subject: [PATCH 05/50] Give debug builds their own applicationId Debug and release shared applicationId but are signed with different keys, so installing a debug build onto a device with a release install fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. The IDE then offers to uninstall the existing app to proceed, which permanently deletes the imported beacons and the accumulated location history. android:allowBackup is false, so there is no backup to fall back on, and recovering means redoing the macOS export - the exact thing this app exists to avoid. Suffixing the debug applicationId lets a debug build install alongside a real one, which also makes it possible to test the v1 to v2 migration against a realistic database without putting a production install at risk. A Maps API key restricted to the release package will not authorise the debug package; see the note in build.gradle.kts. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a270a87..0901f142 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,19 @@ android { } debug { isDebuggable = true + + // Install debug builds alongside a release install rather than colliding with + // it. Without this, both share the applicationId but are signed with different + // keys, so installing a debug build over a real one fails with + // INSTALL_FAILED_UPDATE_INCOMPATIBLE and the only way forward is to uninstall - + // which permanently destroys the user's imported beacons and location history. + // allowBackup is false, so there is no backup to restore from either. + // + // Note: a Maps API key restricted to the release package name will not authorise + // this one. Add "dev.wander.android.opentagviewer.debug" (with the debug keystore + // SHA-1) to the key's restrictions if you need maps to render in debug builds. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" } } compileOptions { From e0096ef7741695db18b6159eef9f9964fbf86331 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 13:51:19 +0200 Subject: [PATCH 06/50] Label debug builds distinctly on the launcher With the debug applicationId suffixed, a debug build installs alongside a real one - but both used the same icon and the same "OpenTagViewer" label, so there was no way to tell which was which on the launcher. That is actively dangerous here: the two apps hold different databases, and mistaking the real one for the test one invites exactly the destructive uninstall the suffix was added to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 6 ++++++ app/src/main/AndroidManifest.xml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0901f142..5af5aa00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,8 @@ android { buildTypes { release { + // Keeps the launcher name as-is for real installs. + manifestPlaceholders["appLabel"] = "@string/app_name" signingConfig = signingConfigs.getByName("release") isDebuggable = false isMinifyEnabled = false @@ -94,6 +96,10 @@ android { // SHA-1) to the key's restrictions if you need maps to render in debug builds. applicationIdSuffix = ".debug" versionNameSuffix = "-debug" + + // Distinct launcher name, otherwise a debug install sits next to a real one + // with an identical icon and label and there is no way to tell them apart. + manifestPlaceholders["appLabel"] = "OpenTagViewer (debug)" } } compileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2ac8ca0..468bb19f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" + android:label="${appLabel}" android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" From 7314bc41f25cb24ba2d621d7664d7145d6c2f70a Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 16:35:17 +0200 Subject: [PATCH 07/50] Run instrumented tests in CI The Room migration tests live in src/androidTest because MIGRATION_1_2 can only be exercised on a real Android runtime, and CI only ran JVM-only tasks, so the regression test guarding against destroying every existing user's beacons and location history never actually executed. Adds an emulator job running connectedDebugAndroidTest, with reports uploaded on failure. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-debug.yml | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 1c8e240f..adbc2288 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -34,6 +34,66 @@ on: workflow_dispatch: jobs: + # The Room v1 -> v2 migration can only be exercised on a real Android runtime, so it + # lives in src/androidTest and needs an emulator. `./gradlew test` in the build job + # below runs JVM unit tests only and would silently skip it - which matters because a + # broken migration destroys every existing user's imported beacons and location + # history, and only reproduces on upgrade, never on a fresh install. + instrumented-tests: + name: Instrumented tests (emulator) + environment: 'Android Build' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set Up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + + - name: Inject secrets.properties + run: | + echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + + # KVM is required for a hardware-accelerated emulator; without it the run times out. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + ram-size: 4096M + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: ./gradlew connectedDebugAndroidTest --console=plain + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-reports + path: app/build/reports/androidTests/ + if-no-files-found: ignore + build: environment: 'Android Build' runs-on: ubuntu-latest From 7509d2cc618430a4f7757edac5fc5e09155786da Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 14:59:50 +0200 Subject: [PATCH 08/50] Make the accessory_json backfill testable and cover it The backfill is what stops beacons imported under FindMy 0.7.6 from being excluded from every future fetch after upgrading. It had no coverage because toAccessoryRequests called Python.getInstance() directly, which cannot run on the JVM or without a started Chaquopy runtime. Puts the conversion behind PlistToAccessoryJsonConverter so tests can supply a fake, and adds coverage for the cases that matter: a NULL column is converted and persisted, an already-populated column is not reconverted, a failed conversion leaves the column NULL so it stays retryable rather than being poisoned, one unconvertible beacon does not drop the others from the batch, and storeFetchResult writes the updated rolling-key alignment back without letting a null wipe a good value. Two fixes fall out of the refactor. Python.getInstance() was previously called on every invocation even when no beacon needed converting, which throws if the runtime has not started; it is now only reached when a conversion is actually required. And the backfill now prefers the plist stored on the row over the caller's copy, since the row is the source of truth and the caller's map can be stale. Co-Authored-By: Gare Co-Authored-By: Claude Opus 5 (1M context) --- .../db/repo/BeaconRepositoryBackfillTest.java | 244 ++++++++++++++++++ .../db/repo/BeaconRepository.java | 49 ++-- ...ChaquopyPlistToAccessoryJsonConverter.java | 35 +++ .../python/PlistToAccessoryJsonConverter.java | 21 ++ 4 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java new file mode 100644 index 00000000..762f1690 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java @@ -0,0 +1,244 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.room.Room; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter; + +/** + * Covers the lazy {@code accessory_json} backfill. + *
+ * Beacons imported under FindMy 0.7.6 have no accessory JSON, and the v1 → v2 migration + * deliberately leaves the column NULL. Without a working backfill those beacons are + * excluded from every future fetch - they simply stop updating, with nothing but a + * logcat warning to show for it. That is a silent failure on the user's existing data, + * so it needs covering directly. + */ +@RunWith(AndroidJUnit4.class) +public class BeaconRepositoryBackfillTest { + + private static final String PLIST = ""; + private static final String CONVERTED_JSON = "{\"type\":\"accessory\",\"converted\":true}"; + + private OpenTagViewerDatabase db; + + /** Records what it was asked to convert so tests can assert on call count and input. */ + private static class RecordingConverter implements PlistToAccessoryJsonConverter { + final List calls = new ArrayList<>(); + private final String result; + + RecordingConverter(String result) { + this.result = result; + } + + @Override + public String convert(String plistXml) { + this.calls.add(plistXml); + return this.result; + } + } + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + this.db = Room.inMemoryDatabaseBuilder(context, OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + } + + @After + public void tearDown() { + this.db.close(); + } + + private void insertBeacon(String beaconId, String content, String accessoryJson) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(beaconId) + .importId(null) + .content(content) + .version("1.0") + .isRemoved(false) + .accessoryJson(accessoryJson) + .build()); + } + + /** The migrated case: NULL accessory_json is converted from the retained plist and persisted. */ + @Test + public void backfillsAndPersistsWhenAccessoryJsonIsNull() { + insertBeacon("beacon-a", PLIST, null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertEquals("beacon-a", requests.get(0).getBeaconId()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + + assertEquals("converter should have been called exactly once", 1, converter.calls.size()); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("backfill must be persisted, not just returned", CONVERTED_JSON, stored.accessoryJson); + } + + /** Once backfilled, later fetches must not pay for the Chaquopy round trip again. */ + @Test + public void doesNotReconvertWhenAccessoryJsonAlreadyPresent() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var converter = new RecordingConverter("SHOULD-NOT-BE-USED"); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + assertTrue("converter must not be called when the column is populated", converter.calls.isEmpty()); + } + + /** + * Conversion failing is not permanent - Python may simply not be up yet - so the row + * must be left NULL and retried next time, never poisoned with a bad value. + */ + @Test + public void skipsBeaconAndLeavesColumnNullWhenConversionFails() { + insertBeacon("beacon-a", PLIST, null); + var converter = new RecordingConverter(null); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertTrue("unconvertible beacon must be dropped from the batch", requests.isEmpty()); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertNull("a failed conversion must remain retryable", stored.accessoryJson); + } + + /** One bad beacon must not take down the fetch for the others. */ + @Test + public void oneFailedConversionDoesNotDropTheOthers() { + insertBeacon("good-1", PLIST, CONVERTED_JSON); + insertBeacon("bad", PLIST, null); + insertBeacon("good-2", PLIST, CONVERTED_JSON); + + // Fails only for the beacon that needs converting. + var converter = new RecordingConverter(null); + var repo = new BeaconRepository(this.db, converter); + + List requests = repo.toAccessoryRequests(Map.of( + "good-1", PLIST, + "bad", PLIST, + "good-2", PLIST + )).blockingFirst(); + + assertEquals("the two healthy beacons should still be fetched", 2, requests.size()); + for (AccessoryRequest request : requests) { + assertNotNull(request.getAccessoryJson()); + } + } + + /** + * The row's retained plist is the source of truth, not the caller's map, which can be + * stale. Guards the migration path where the caller passes whatever it had in memory. + */ + @Test + public void prefersStoredPlistOverCallerSuppliedOne() { + insertBeacon("beacon-a", "STORED-PLIST", null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + repo.toAccessoryRequests(Map.of("beacon-a", "CALLER-PLIST")).blockingFirst(); + + assertEquals(1, converter.calls.size()); + assertEquals("STORED-PLIST", converter.calls.get(0)); + } + + /** A beacon that was never imported must not blow up the batch. */ + @Test + public void handlesMissingRowGracefully() { + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("never-imported", PLIST)).blockingFirst(); + + // Falls back to the caller's plist since there is no row to read one from. + assertEquals(1, requests.size()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + } + + /** No beacons requested means no work and no Python round trip. */ + @Test + public void emptyRequestDoesNoWork() { + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Collections.emptyMap()).blockingFirst(); + + assertTrue(requests.isEmpty()); + assertTrue(converter.calls.isEmpty()); + } + + /** + * The whole point of issue #30: the rolling-key alignment returned by a fetch has to + * land back in the database, or key drift re-emerges on the next fetch. + */ + @Test + public void storeFetchResultPersistsUpdatedAccessoryJson() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var repo = new BeaconRepository(this.db, new RecordingConverter(null)); + + final String updated = "{\"type\":\"accessory\",\"alignment_index\":4242}"; + var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( + Collections.emptyMap(), + Map.of("beacon-a", updated) + ); + + repo.storeFetchResult(fetchResult).blockingFirst(); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("updated key alignment must be persisted", updated, stored.accessoryJson); + } + + /** A null value in the fetch result must not wipe a good stored alignment. */ + @Test + public void storeFetchResultIgnoresNullUpdates() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var repo = new BeaconRepository(this.db, new RecordingConverter(null)); + + var updates = new java.util.HashMap(); + updates.put("beacon-a", null); + var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( + Collections.emptyMap(), updates); + + repo.storeFetchResult(fetchResult).blockingFirst(); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("existing alignment must survive a null update", CONVERTED_JSON, stored.accessoryJson); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index 35788346..4dc8ab8a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -2,8 +2,6 @@ import android.util.Log; -import com.chaquo.python.Python; - import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -24,7 +22,9 @@ import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.ChaquopyPlistToAccessoryJsonConverter; import dev.wander.android.opentagviewer.python.FetchResult; +import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter; import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -34,9 +34,19 @@ public class BeaconRepository { private final static String TAG = BeaconRepository.class.getSimpleName(); private final OpenTagViewerDatabase db; + private final PlistToAccessoryJsonConverter accessoryJsonConverter; public BeaconRepository(OpenTagViewerDatabase db) { + this(db, new ChaquopyPlistToAccessoryJsonConverter()); + } + + /** + * Injectable converter, so the lazy accessory_json backfill can be tested without a + * running Python runtime. + */ + public BeaconRepository(OpenTagViewerDatabase db, PlistToAccessoryJsonConverter accessoryJsonConverter) { this.db = db; + this.accessoryJsonConverter = accessoryJsonConverter; } /** @@ -146,39 +156,36 @@ public Observable> toAccessoryRequests(Map out = new ArrayList<>(beaconIdToPlistFallback.size()); for (var entry : beaconIdToPlistFallback.entrySet()) { final String beaconId = entry.getKey(); - final String plistFallback = entry.getValue(); - String accessoryJson = null; final OwnedBeacon row = dao.getById(beaconId); - if (row != null) { - accessoryJson = row.accessoryJson; - } + String accessoryJson = row == null ? null : row.accessoryJson; if (accessoryJson == null) { - // Lazy backfill: rows imported under FindMy 0.7.6 won't have accessory_json. - try { - var converted = module.callAttr("convertPlistToJson", plistFallback); - if (converted != null) { - accessoryJson = converted.toString(); - dao.updateAccessoryJson(beaconId, accessoryJson); - Log.d(TAG, "Lazy-backfilled accessory_json for beaconId=" + beaconId); - } - } catch (Exception e) { - Log.w(TAG, "convertPlistToJson failed for beaconId=" + beaconId - + "; this beacon will be skipped this fetch", e); + // Lazy backfill: rows imported under FindMy 0.7.6 have no accessory_json, + // and neither do rows whose import-time conversion failed. Prefer the + // plist retained on the row over the caller's copy - the row is the + // source of truth, and the caller's map may be stale. + final String plist = (row != null && row.content != null) + ? row.content + : entry.getValue(); + + accessoryJson = this.accessoryJsonConverter.convert(plist); + if (accessoryJson != null) { + dao.updateAccessoryJson(beaconId, accessoryJson); + Log.d(TAG, "Lazy-backfilled accessory_json for beaconId=" + beaconId); } } if (accessoryJson != null) { out.add(new AccessoryRequest(beaconId, accessoryJson)); } else { - Log.w(TAG, "Skipping beaconId=" + beaconId + " — no accessory_json available"); + // Dropped rather than passed on: FindMyAccessory.from_json would throw + // and take down the whole batch. A short result is better than none. + Log.w(TAG, "Skipping beaconId=" + beaconId + " - no accessory_json available"); } } return out; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java new file mode 100644 index 00000000..d58a49fd --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java @@ -0,0 +1,35 @@ +package dev.wander.android.opentagviewer.python; + +import android.util.Log; + +import com.chaquo.python.Python; + +/** + * The real converter: calls {@code main.py:convertPlistToJson}, which runs + * {@code FindMyAccessory.from_plist(...).to_json()}. + *
+ * The Python runtime is resolved lazily per call rather than held as a field, so + * constructing this does not require Chaquopy to have started. + */ +public class ChaquopyPlistToAccessoryJsonConverter implements PlistToAccessoryJsonConverter { + private static final String TAG = ChaquopyPlistToAccessoryJsonConverter.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + @Override + public String convert(final String plistXml) { + if (plistXml == null || plistXml.isEmpty()) { + return null; + } + + try { + var module = Python.getInstance().getModule(MODULE_MAIN); + var converted = module.callAttr("convertPlistToJson", plistXml); + return converted == null ? null : converted.toString(); + } catch (Exception e) { + // Either Python has not started yet, or the plist is not one FindMy 0.9.x can + // parse. Neither should take down the whole fetch, so report and move on. + Log.w(TAG, "convertPlistToJson failed", e); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java new file mode 100644 index 00000000..a8c220b8 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java @@ -0,0 +1,21 @@ +package dev.wander.android.opentagviewer.python; + +/** + * Converts a beacon's stored plist XML into the serialized {@code FindMyAccessory} JSON + * that FindMy.py 0.9.x expects. + *
+ * This is an interface rather than a static call so the conversion can be substituted in + * tests: the real implementation reaches into Chaquopy, which needs a started Python + * runtime and cannot run on the JVM. + */ +public interface PlistToAccessoryJsonConverter { + + /** + * @param plistXml the raw plist XML as stored in {@code OwnedBeacons.content} + * @return the serialized accessory JSON, or {@code null} if conversion was not + * possible. Callers should treat {@code null} as "not available right now" + * and retry later, not as a permanent failure - a conversion can fail simply + * because the Python runtime has not started yet. + */ + String convert(String plistXml); +} From 2e28a45753485a9a31fcfb74fc2e27794f6042e1 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 15:09:48 +0200 Subject: [PATCH 09/50] Probe for alignment before wide key searches, and cover main.py The export wizard captures OwnedBeacons/ and BeaconNamingRecord/ but no key alignment record, so from_plist gets no key_alignment_plist and a converted accessory starts at alignment_date = paired_at, alignment_index = 0. The key search then spans the entire life of the tag. Measured against a real (redacted) export: a 526-day-old AirTag needs 50,563 key indices on its first fetch. Apple accepts ~290 keys per request, so that is roughly 175 round trips for one tag - and it happens for every old tag at once, the first time the app runs after upgrading. That is the account-flagging risk raised in issue #30. fetch_location asks for the latest report only, which makes FindMy.py walk backwards from now and stop at the first hit rather than traversing the whole range. Alignment is updated as a side effect of any successful fetch, so one cheap probe collapses the window for everything after it, and the result is persisted through updatedAccessoryJson. Worst case - a tag with no reports at all - it costs the same traversal the history fetch would have done anyway, so it is never a net loss. Adds pytest coverage for main.py: the plist conversion against a real redacted export, the client-side time filtering that replaced 0.7.6's server-side windowing (including inclusive boundaries), the serialized shape Java's mapResults depends on, and the probe's threshold and failure handling. Also guards that the tested FindMy version matches what Chaquopy installs. Fixtures are a redacted copy of a real export, keeping the true structure so a parse regression from an unexpected field can actually be caught. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/python/main.py | 62 ++++ app/src/test/python/conftest.py | 18 ++ app/src/test/python/requirements.txt | 10 + app/src/test/python/test_main.py | 270 ++++++++++++++++++ ...6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB.plist | 20 ++ .../test/resources/19032025/OPENTAGVIEWER.yml | 4 + ...F612A183-492B-45A8-A5A2-233CA9062A94.plist | 73 +++++ app/src/test/resources/README.md | 50 ++++ scripts/check_export_compatibility.py | 124 ++++++++ scripts/make_test_beacon_plist.py | 72 +++++ 10 files changed, 703 insertions(+) create mode 100644 app/src/test/python/conftest.py create mode 100644 app/src/test/python/requirements.txt create mode 100644 app/src/test/python/test_main.py create mode 100644 app/src/test/resources/19032025/BeaconNamingRecord/F612A183-492B-45A8-A5A2-233CA9062A94/6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB.plist create mode 100644 app/src/test/resources/19032025/OPENTAGVIEWER.yml create mode 100644 app/src/test/resources/19032025/OwnedBeacons/F612A183-492B-45A8-A5A2-233CA9062A94.plist create mode 100644 app/src/test/resources/README.md create mode 100644 scripts/check_export_compatibility.py create mode 100644 scripts/make_test_beacon_plist.py diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 9699eea6..8f24fb22 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -243,6 +243,60 @@ def _filterReportsByTimeRange(reports, startMs, endMs): return out +# Apple accepts at most ~290 hashed keys per request, and FindMy.py walks the key +# index range one step at a time (15 minutes per step for an AirTag). A window wider +# than this is worth paying one cheap probe to avoid. +_ALIGNMENT_PROBE_THRESHOLD_INDICES = 2000 + + +def _narrowAlignmentIfNeeded(account: AppleAccount, accessory: FindMyAccessory, start, end) -> bool: + """ + Collapse a wide key-search window before doing a history fetch. + + The export wizard captures no key alignment record, so a freshly converted + accessory starts at `alignment_date = paired_at, alignment_index = 0`. The key + search then spans the whole life of the tag: a two-year-old AirTag is ~70,000 + indices, which at 290 keys per request is a few hundred round trips to Apple on + the very first fetch after upgrading. That is the account-flagging risk raised in + issue #30. + + `fetch_location` asks for the latest report only, which makes FindMy.py walk + backwards from now and stop at the first hit instead of traversing the entire + range. Alignment is updated as a side effect of any successful fetch, so one cheap + call collapses the window for everything that follows. The updated alignment is + persisted by the caller via updatedAccessoryJson. + + Worst case - a tag with no reports at all - this costs the same traversal the + history fetch would have done anyway, so it is never a net loss. + + :returns: True if a probe was performed. + """ + try: + width = accessory.get_max_index(end) - accessory.get_min_index(start) + except Exception: + print(f"Could not determine key index width: {traceback.format_exc()}") + return False + + if width <= _ALIGNMENT_PROBE_THRESHOLD_INDICES: + return False + + print(f"Key search window is {width} indices wide; probing for latest report first " + f"to establish alignment before fetching history.") + try: + account.fetch_location(accessory) + except Exception: + # A failed probe is not fatal - fall through to the full history fetch. + print(f"Alignment probe failed, continuing anyway: {traceback.format_exc()}") + return False + + try: + narrowed = accessory.get_max_index(end) - accessory.get_min_index(start) + print(f"Key search window narrowed from {width} to {narrowed} indices.") + except Exception: + pass + return True + + def _serializeReports(reports): """ Map FindMy 0.9.x LocationReport objects to the dict shape Java's mapResults expects. @@ -299,6 +353,10 @@ def getLastReports( airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc) + now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) + _narrowAlignmentIfNeeded(account, airtag, start_dt, now_dt) + reports = account.fetch_location_history(airtag) if reports is None: reports = [] @@ -349,6 +407,10 @@ def getReports( airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc) + end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc) + _narrowAlignmentIfNeeded(account, airtag, start_dt, end_dt) + reports = account.fetch_location_history(airtag) if reports is None: reports = [] diff --git a/app/src/test/python/conftest.py b/app/src/test/python/conftest.py new file mode 100644 index 00000000..fcedab57 --- /dev/null +++ b/app/src/test/python/conftest.py @@ -0,0 +1,18 @@ +"""Make the app's Chaquopy sources importable by the tests. + +app/src/main/python is what Chaquopy packages into the APK; nothing in it may +import Android or Java types, which is exactly what makes it testable on a plain +CPython interpreter. +""" + +import sys +from pathlib import Path + +APP_PYTHON = Path(__file__).resolve().parents[2] / "main" / "python" +RESOURCES = Path(__file__).resolve().parents[1] / "resources" + +sys.path.insert(0, str(APP_PYTHON)) + + +def pytest_configure(config): + config.addinivalue_line("markers", "fixture_required: needs a redacted beacon plist") diff --git a/app/src/test/python/requirements.txt b/app/src/test/python/requirements.txt new file mode 100644 index 00000000..c143c3af --- /dev/null +++ b/app/src/test/python/requirements.txt @@ -0,0 +1,10 @@ +# Dependencies for testing app/src/main/python on a normal CPython interpreter. +# +# These MUST match the pins in app/build.gradle.kts (the chaquopy { pip { ... } } +# block), otherwise the tests exercise a different library version than the app +# actually ships. test_main.py::test_pinned_versions_match_the_app_build asserts +# they stay in sync. +FindMy==0.9.8 +NSKeyedUnArchiver==1.5 + +pytest>=8.0 diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py new file mode 100644 index 00000000..11ad187e --- /dev/null +++ b/app/src/test/python/test_main.py @@ -0,0 +1,270 @@ +"""Tests for the Chaquopy bridge module (app/src/main/python/main.py). + +These run on plain CPython - no Android, no emulator, no Apple account - and +cover the parts of the FindMy 0.9.x migration that are pure logic: + + * converting an exported plist to the accessory JSON the new library needs + * the client-side time filtering that replaced 0.7.6's server-side windowing + * the alignment probe that keeps the first fetch after an upgrade from + hammering Apple with hundreds of requests +""" + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import json +import pytest + +import main + +RESOURCES = Path(__file__).resolve().parents[1] / "resources" +BEACON_PLISTS = sorted(RESOURCES.glob("*/OwnedBeacons/*.plist")) + + +def _read_plist_xml(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +class FakeReport: + """Minimal stand-in for findmy LocationReport - only what _serialize/_filter touch.""" + + def __init__(self, timestamp, latitude=52.0, longitude=4.0): + self.timestamp = timestamp + self.latitude = latitude + self.longitude = longitude + self.confidence = 2 + self.horizontal_accuracy = 10 + self.status = 0 + + # sorted() is called on these in _serializeReports + def __lt__(self, other): + return self.timestamp < other.timestamp + + +def _ms(dt: datetime) -> int: + return int(dt.timestamp() * 1000) + + +# -------------------------------------------------------------------------- +# convertPlistToJson +# -------------------------------------------------------------------------- + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +@pytest.mark.parametrize("plist_path", BEACON_PLISTS, ids=lambda p: p.parent.parent.name) +def test_convertPlistToJson_produces_restorable_json(plist_path): + """The exact path a pre-0.9 beacon takes during the lazy backfill.""" + result = main.convertPlistToJson(_read_plist_xml(plist_path)) + + assert result is not None, "a real exported plist must convert" + parsed = json.loads(result) + assert parsed.get("type") == "accessory" + + # Must survive the DB round trip: this JSON is stored in accessory_json and + # read back via from_json on every subsequent fetch. + from findmy import FindMyAccessory + restored = FindMyAccessory.from_json(parsed) + assert restored is not None + + +def test_convertPlistToJson_returns_none_on_garbage(): + """Failure must be None, not an exception - the caller retries later.""" + assert main.convertPlistToJson("not a plist at all") is None + + +def test_convertPlistToJson_returns_none_on_empty(): + assert main.convertPlistToJson("") is None + + +# -------------------------------------------------------------------------- +# _filterReportsByTimeRange +# +# 0.9.x removed the server-side time window, so this filtering is now the only +# thing enforcing the range the Java side asked for. +# -------------------------------------------------------------------------- + +def test_filter_includes_reports_inside_the_window(): + now = datetime.now(tz=timezone.utc) + inside = FakeReport(now - timedelta(hours=1)) + out = main._filterReportsByTimeRange([inside], _ms(now - timedelta(hours=24)), _ms(now)) + assert out == [inside] + + +def test_filter_excludes_reports_before_the_window(): + now = datetime.now(tz=timezone.utc) + old = FakeReport(now - timedelta(days=30)) + out = main._filterReportsByTimeRange([old], _ms(now - timedelta(hours=24)), _ms(now)) + assert out == [] + + +def test_filter_excludes_reports_after_the_window(): + now = datetime.now(tz=timezone.utc) + future = FakeReport(now + timedelta(hours=2)) + out = main._filterReportsByTimeRange([future], _ms(now - timedelta(hours=24)), _ms(now)) + assert out == [] + + +def test_filter_boundaries_are_inclusive(): + """A report exactly on either edge must be kept, not silently dropped.""" + now = datetime.now(tz=timezone.utc).replace(microsecond=0) + start = now - timedelta(hours=24) + + at_start = FakeReport(start) + at_end = FakeReport(now) + + out = main._filterReportsByTimeRange([at_start, at_end], _ms(start), _ms(now)) + assert out == [at_start, at_end] + + +def test_filter_skips_reports_without_a_timestamp(): + now = datetime.now(tz=timezone.utc) + out = main._filterReportsByTimeRange([FakeReport(None)], _ms(now - timedelta(hours=1)), _ms(now)) + assert out == [] + + +def test_filter_with_no_bounds_keeps_everything(): + now = datetime.now(tz=timezone.utc) + reports = [FakeReport(now - timedelta(days=d)) for d in range(5)] + assert main._filterReportsByTimeRange(reports, None, None) == reports + + +# -------------------------------------------------------------------------- +# _serializeReports +# -------------------------------------------------------------------------- + +def test_serializeReports_shape_matches_java_expectations(): + now = datetime.now(tz=timezone.utc) + items = main._serializeReports([FakeReport(now)]) + + assert len(items) == 1 + item = items[0] + # PythonAppleService.mapResults reads exactly these keys. + for key in ("publishedAt", "description", "timestamp", "confidence", + "latitude", "longitude", "horizontalAccuracy", "status"): + assert key in item, f"missing {key} - would break mapResults on the Java side" + + # published_at no longer exists in 0.9.x, so it mirrors timestamp. + assert item["publishedAt"] == item["timestamp"] + assert item["description"] == "" + + +def test_serializeReports_sorts_chronologically(): + now = datetime.now(tz=timezone.utc) + unsorted_reports = [ + FakeReport(now), + FakeReport(now - timedelta(hours=5)), + FakeReport(now - timedelta(hours=2)), + ] + items = main._serializeReports(unsorted_reports) + timestamps = [i["timestamp"] for i in items] + assert timestamps == sorted(timestamps) + + +# -------------------------------------------------------------------------- +# _narrowAlignmentIfNeeded +# +# Guards the issue #30 concern: an unaligned accessory searches its whole +# lifetime of key indices, which at ~290 keys per request is hundreds of calls +# to Apple on the first fetch after upgrading. +# -------------------------------------------------------------------------- + +class FakeAccessory: + def __init__(self, width): + self._width = width + self.narrowed = False + + def get_min_index(self, _dt): + return 0 + + def get_max_index(self, _dt): + return 0 if self.narrowed else self._width + + +class FakeAccount: + def __init__(self, on_fetch=None): + self.fetch_location_calls = 0 + self._on_fetch = on_fetch + + def fetch_location(self, accessory): + self.fetch_location_calls += 1 + if self._on_fetch: + self._on_fetch(accessory) + return None + + +def test_probe_runs_when_the_key_window_is_wide(): + """A two-year-old tag is ~70k indices - exactly the case worth probing.""" + accessory = FakeAccessory(width=70_000) + account = FakeAccount(on_fetch=lambda acc: setattr(acc, "narrowed", True)) + now = datetime.now(tz=timezone.utc) + + probed = main._narrowAlignmentIfNeeded(account, accessory, now - timedelta(hours=24), now) + + assert probed is True + assert account.fetch_location_calls == 1 + + +def test_probe_skipped_when_the_window_is_already_narrow(): + """An aligned accessory must not pay an extra request on every fetch.""" + accessory = FakeAccessory(width=96) # ~1 day for an AirTag + account = FakeAccount() + now = datetime.now(tz=timezone.utc) + + probed = main._narrowAlignmentIfNeeded(account, accessory, now - timedelta(hours=24), now) + + assert probed is False + assert account.fetch_location_calls == 0 + + +def test_probe_failure_is_not_fatal(): + """A failed probe must fall through to the normal fetch, not abort it.""" + class ExplodingAccount: + def fetch_location(self, accessory): + raise RuntimeError("anisette server unreachable") + + accessory = FakeAccessory(width=70_000) + now = datetime.now(tz=timezone.utc) + + assert main._narrowAlignmentIfNeeded(ExplodingAccount(), accessory, now - timedelta(hours=24), now) is False + + +# -------------------------------------------------------------------------- +# Guard against the test environment drifting from what the app ships +# -------------------------------------------------------------------------- + +def test_pinned_versions_match_the_app_build(): + """ + These tests only mean anything if they run against the same library version + Chaquopy installs into the APK. Keeps requirements.txt honest. + """ + import re + + gradle = (Path(__file__).resolve().parents[3] / "build.gradle.kts").read_text(encoding="utf-8") + requirements = (Path(__file__).resolve().parent / "requirements.txt").read_text(encoding="utf-8") + + installed = dict(re.findall(r'install\("([A-Za-z_][\w.-]*)==([\d.]+)"\)', gradle)) + required = dict(re.findall(r'^([A-Za-z_][\w.-]*)==([\d.]+)', requirements, re.MULTILINE)) + + assert installed, "could not find any pinned pip installs in build.gradle.kts" + + for package, version in installed.items(): + assert package in required, ( + f"{package} is installed by Chaquopy but not pinned in requirements.txt" + ) + assert required[package] == version, ( + f"{package} is {version} in build.gradle.kts but {required[package]} in requirements.txt" + ) + + +def test_probe_survives_an_accessory_that_cannot_report_indices(): + class BrokenAccessory: + def get_min_index(self, _dt): + raise ValueError("nope") + + def get_max_index(self, _dt): + raise ValueError("nope") + + account = FakeAccount() + now = datetime.now(tz=timezone.utc) + + assert main._narrowAlignmentIfNeeded(account, BrokenAccessory(), now - timedelta(hours=24), now) is False + assert account.fetch_location_calls == 0 diff --git a/app/src/test/resources/19032025/BeaconNamingRecord/F612A183-492B-45A8-A5A2-233CA9062A94/6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB.plist b/app/src/test/resources/19032025/BeaconNamingRecord/F612A183-492B-45A8-A5A2-233CA9062A94/6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB.plist new file mode 100644 index 00000000..0543545f --- /dev/null +++ b/app/src/test/resources/19032025/BeaconNamingRecord/F612A183-492B-45A8-A5A2-233CA9062A94/6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB.plist @@ -0,0 +1,20 @@ + + + + + associatedBeacon + F612A183-492B-45A8-A5A2-233CA9062A94 + cloudKitMetadata + + TODO + + emoji + 🐈 + identifier + 6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB + name + cat + roleId + 999 + + diff --git a/app/src/test/resources/19032025/OPENTAGVIEWER.yml b/app/src/test/resources/19032025/OPENTAGVIEWER.yml new file mode 100644 index 00000000..a81272bc --- /dev/null +++ b/app/src/test/resources/19032025/OPENTAGVIEWER.yml @@ -0,0 +1,4 @@ +exportTimestamp: 1742442066311 +sourceUser: user +version: 0.0.1 +via: OpenTagViewer.app:1.0.0 diff --git a/app/src/test/resources/19032025/OwnedBeacons/F612A183-492B-45A8-A5A2-233CA9062A94.plist b/app/src/test/resources/19032025/OwnedBeacons/F612A183-492B-45A8-A5A2-233CA9062A94.plist new file mode 100644 index 00000000..7ef2f397 --- /dev/null +++ b/app/src/test/resources/19032025/OwnedBeacons/F612A183-492B-45A8-A5A2-233CA9062A94.plist @@ -0,0 +1,73 @@ + + + + + batteryLevel + 1 + cloudKitMetadata + + TODO + + identifier + F612A183-492B-45A8-A5A2-233CA9062A94 + isZeus + + model + + pairingDate + 2025-02-27T20:03:32Z + privateKey + + key + + data + + J1AAk7qStLSbMhZT/XEve6by7hI0H7CslD/Oh7SrOc+mlmLnAO8c + 5FGnhi/s3TDlWNiL3SMy19NQuCWg6oTS+YfBZN79RiUmZtssTp9f + UvZjmqMX3o== + + + + productId + 21760 + publicKey + + key + + data + + k6fWaOxFGbClYV6tu/ZK4vXdyWl2joSbJhbzu12Pfmf5p09w5LxK + IvnABRfysSFkOAlo/F3Ii9Dq + + + + secondarySharedSecret + + key + + data + + 1pWMT+FI3flAWmgbUEW5H6omZy+yZOzp30zZGxEa2A8= + + + + sharedSecret + + key + + data + + vM2ZjU/sKW/novHcwzTlY5xwGLOUOZjpgcZa9cNx2Y+= + + + + stableIdentifier + + 2001~#001234a12345aaac~#A02BCDEFG1AB + + systemVersion + 2.0.73 + vendorId + 76 + + diff --git a/app/src/test/resources/README.md b/app/src/test/resources/README.md new file mode 100644 index 00000000..056b0f5c --- /dev/null +++ b/app/src/test/resources/README.md @@ -0,0 +1,50 @@ +# Test resources + +## `19032025/` + +A redacted copy of a real OpenTagViewer export (`OPENTAGVIEWER.yml` reports +`version: 0.0.1`, `via: OpenTagViewer.app:1.0.0`, exported 2025-03-20). The +directory layout mirrors a genuine export so tests can exercise the same paths +the importer does: + +``` +19032025/ + OPENTAGVIEWER.yml + OwnedBeacons/.plist + BeaconNamingRecord//.plist +``` + +Keeping a real export's shape is the point. A hand-written fixture only contains +the fields we already knew to write, so it cannot catch a parse regression caused +by a field Apple writes that we did not account for. + +### What was redacted + +- every key blob (`privateKey`, `sharedSecret`, `secondarySharedSecret`) was + replaced with characters from the base64 alphabet, preserving the original + lengths — `FindMyAccessory.from_plist` only reads byte lengths, so the fixture + parses identically while carrying no real key material +- UUIDs were replaced +- `cloudKitMetadata` was stubbed rather than redacted. It is an NSKeyedArchiver + blob whose `ModifiedByDevice` field is a human device name, so it is not + something to publish. Consequence: `decodeBeaconNamingRecordCloudKitMetadata` + returns null for this fixture, and the record ctime/mtime extraction path is + not covered. + +`pairingDate` is genuine, which matters: with no key alignment record in the +export, FindMy.py falls back to `alignment_date = paired_at, alignment_index = 0`, +so tag age alone determines how wide the first key search is. This fixture is +~526 days old (as of time of writing at 8 august 2026), which works out at roughly +50,000 key indices — about 175 requests to Apple at the 290-keys-per-request limit. +That is what `_narrowAlignmentIfNeeded` in `main.py` exists to avoid. + +### Nothing here is sensitive + +These files are committed to a public repo. The filler bytes are not derived from +the originals; only lengths carried over. + +## `beacons/` + +Reserved for additional fixtures of differing tag ages, for testing how the +alignment probe behaves across the range. See `scripts/make_test_beacon_plist.py` +to generate synthetic ones. \ No newline at end of file diff --git a/scripts/check_export_compatibility.py b/scripts/check_export_compatibility.py new file mode 100644 index 00000000..3e4d9768 --- /dev/null +++ b/scripts/check_export_compatibility.py @@ -0,0 +1,124 @@ +"""Check whether a real OpenTagViewer export can be parsed by FindMy 0.9.x. + +Run this against your own export directory before upgrading. It answers one +question: would the lazy accessory_json backfill succeed for each of your +beacons, or would any of them be silently skipped and stop updating? + +It deliberately prints only derived facts - parse success, which optional plist +fields are present, tag age, and the resulting first-fetch key-search size. It +never prints file contents, key material, identifiers, or filenames, so the +output is safe to paste into an issue. + +Usage: + python scripts/check_export_compatibility.py +""" + +from __future__ import annotations + +import argparse +import plistlib +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +try: + from findmy import FindMyAccessory +except ImportError: + print( + "FindMy is not installed in this interpreter.\n" + " python -m venv .venv && .venv/Scripts/pip install 'FindMy==0.9.8'", + file=sys.stderr, + ) + raise SystemExit(2) from None + +# Fields from_plist reads. Presence, not value, is what we report. +REQUIRED = ("privateKey", "sharedSecret", "pairingDate", "model", "identifier") +SECONDARY = ("secondarySharedSecret", "secureLocationsSharedSecret") + + +def _locate(root: Path) -> list[Path]: + owned = root / "OwnedBeacons" + search_root = owned if owned.is_dir() else root + return sorted(p for p in search_root.rglob("*.plist") if p.is_file()) + + +def _field_report(path: Path) -> tuple[list[str], str | None]: + """Which required fields are missing, and which secondary-secret variant is used.""" + try: + with path.open("rb") as fp: + doc = plistlib.load(fp) + except Exception: + return list(REQUIRED), None + + missing = [f for f in REQUIRED if f not in doc] + variant = next((f for f in SECONDARY if f in doc), None) + return missing, variant + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("path", type=Path) + parser.add_argument("--hours-back", type=int, default=24) + args = parser.parse_args(argv[1:]) + + if not args.path.exists(): + print(f"no such path: {args.path}", file=sys.stderr) + return 2 + + plists = _locate(args.path) + if not plists: + print(f"no .plist files found under {args.path}", file=sys.stderr) + return 2 + + now = datetime.now(tz=timezone.utc) + start = now - timedelta(hours=args.hours_back) + + print(f"Checking {len(plists)} beacon(s) against FindMy 0.9.x\n") + + failed = 0 + worst = 0 + for position, path in enumerate(plists, start=1): + missing, variant = _field_report(path) + + try: + accessory = FindMyAccessory.from_plist(path) + except Exception as exc: # noqa: BLE001 - the error type is the useful signal + failed += 1 + print(f" beacon #{position}: PARSE FAILED - {type(exc).__name__}: {exc}") + if missing: + print(f" missing fields: {', '.join(missing)}") + print(" -> would be skipped by the backfill and stop updating\n") + continue + + paired = accessory.paired_at + if paired.tzinfo is None: + paired = paired.replace(tzinfo=timezone.utc) + age_days = (now - paired).days + + keys = max(0, accessory.get_max_index(now) - accessory.get_min_index(start) + 1) + worst = max(worst, keys) + + print(f" beacon #{position}: OK") + print(f" model {accessory.model}") + print(f" secondary secret {variant or '(none found)'}") + print(f" tag age {age_days} days") + print(f" first-fetch keys {keys:,}") + + try: + FindMyAccessory.from_json(accessory.to_json()) + print(" json round trip OK") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f" json round trip FAILED - {type(exc).__name__}: {exc}") + print() + + print(f"Worst-case first fetch across all beacons: {worst:,} keys") + if failed: + print(f"\n{failed} beacon(s) would not survive the upgrade.", file=sys.stderr) + return 1 + print("\nAll beacons parse and round-trip cleanly.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/make_test_beacon_plist.py b/scripts/make_test_beacon_plist.py new file mode 100644 index 00000000..193b5a64 --- /dev/null +++ b/scripts/make_test_beacon_plist.py @@ -0,0 +1,72 @@ +"""Generate synthetic OwnedBeacons plists for tests. + +The key material produced here is deterministic filler - derived from a fixed +formula, not from any real accessory - so the output is safe to commit. It is +structurally valid enough for FindMy.py's ``FindMyAccessory.from_plist()``, +which is all the tests need: none of them decrypt anything or contact Apple. + +The one field that genuinely matters for behaviour is ``pairingDate``. Without a +key alignment record (which the export wizard does not capture) FindMy.py falls +back to ``alignment_date = paired_at, alignment_index = 0``, so the size of the +first-fetch key search is a pure function of how old the tag is. That is why the +fixtures come in several ages. + +Usage: + python scripts/make_test_beacon_plist.py [--days-old N] [--kind airtag|idevice] +""" + +from __future__ import annotations + +import argparse +import plistlib +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def _filler(length: int, seed: int) -> bytes: + """Deterministic non-random bytes. Obviously synthetic, never a real key.""" + return bytes((i * seed + seed) % 256 for i in range(length)) + + +def build(days_old: int, kind: str) -> dict: + paired = datetime.now(tz=timezone.utc) - timedelta(days=days_old) + # plistlib writes naive datetimes as UTC, which is how the real records look. + paired = paired.replace(tzinfo=None, microsecond=0) + + doc = { + # PRIVATE master key - 28 bytes are read from the tail. + "privateKey": {"key": {"data": _filler(28, 7)}}, + # "Primary" shared secret. + "sharedSecret": {"key": {"data": _filler(32, 11)}}, + "pairingDate": paired, + "model": "A2187" if kind == "airtag" else "iPhone14,2", + "identifier": "AABBCCDD-1122-4333-8444-555566667777", + } + + if kind == "airtag": + doc["secondarySharedSecret"] = {"key": {"data": _filler(32, 13)}} + else: + # iDevices carry the secondary secret under a different key. + doc["secureLocationsSharedSecret"] = {"key": {"data": _filler(32, 13)}} + + return doc + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("output", type=Path) + parser.add_argument("--days-old", type=int, default=730, help="age of the pairing (default: 730)") + parser.add_argument("--kind", choices=("airtag", "idevice"), default="airtag") + args = parser.parse_args(argv[1:]) + + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("wb") as fp: + plistlib.dump(build(args.days_old, args.kind), fp, fmt=plistlib.FMT_XML) + + print(f"wrote {args.output} ({args.kind}, paired {args.days_old} days ago)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From b9d90c952799d39a5f1fa703c99de848fd0a8f87 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 15:09:48 +0200 Subject: [PATCH 10/50] Document every test suite and run the missing one in CI Tests are spread across four locations and three runners, and there was no single place saying how to run them. TESTING.md lists the commands for each, what CI covers, and the offline diagnostic scripts that need no Apple account. The Chaquopy bridge tests were not running anywhere in CI; both the debug and release workflows now execute them. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-debug.yml | 13 +++ .github/workflows/build-release.yml | 12 +++ README.md | 4 + TESTING.md | 155 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 TESTING.md diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index adbc2288..20cd1499 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -134,6 +134,19 @@ jobs: - name: Run gradle tests run: ./gradlew test + # Covers app/src/main/python (the module Chaquopy packages into the APK). + # Runs on plain CPython - it imports no Android or Java types. + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Chaquopy bridge test dependencies + run: python -m pip install -r app/src/test/python/requirements.txt + + - name: Run Chaquopy bridge tests + run: python -m pytest app/src/test/python -v + # Create APK Debug - name: Build Debug APK - ${{ env.main_project_module }} module run: ./gradlew testDebugUnitTest assembleDebug diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index bdd923eb..ed4af334 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -87,6 +87,18 @@ jobs: - name: Run gradle tests run: ./gradlew test + # Covers app/src/main/python, the module Chaquopy packages into the APK. + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Chaquopy bridge test dependencies + run: python -m pip install -r app/src/test/python/requirements.txt + + - name: Run Chaquopy bridge tests + run: python -m pytest app/src/test/python -v + - name: Build gradle project run: ./gradlew build -x lint env: diff --git a/README.md b/README.md index f14aae9b..06884206 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ Contributions/MRs are more than welcome. Plenty of things can be improved in this project as it was mostly a "hackathony" thing I tried to throw together ASAP and make presentable for layusers. Plenty of best practices haven't been followed, e.g. in the Testing & CI/CD setup. +📋 **[TESTING.md](./TESTING.md)** lists how to run every test suite in the repo — the Android +unit and instrumented tests, the Chaquopy bridge tests, and the desktop wizard tests — plus what +CI runs and a few offline diagnostic scripts that need no Apple account. + **I think it would be nice if the app could support the following features:** - [`🔴 BLOCKED due to 🐛Bug`](https://github.com/malmeloo/FindMy.py/issues/118) Locate Nearby AirTags using Low-Power Bluetooth & display the latest update in that case diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..b28e4fea --- /dev/null +++ b/TESTING.md @@ -0,0 +1,155 @@ +# Testing + +Tests live in four places, because the code runs in three different environments: +the JVM, an Android runtime, and CPython (both inside the app via Chaquopy and on +the desktop for the export wizard). Each needs a different runner. + +| Suite | Location | Runner | Needs a device? | +| --- | --- | --- | --- | +| Android unit tests | `app/src/test/java/` | Gradle / JUnit | no | +| Android instrumented tests | `app/src/androidTest/java/` | Gradle / JUnit + emulator | **yes** | +| Chaquopy bridge tests | `app/src/test/python/` | pytest | no | +| Desktop wizard tests | `python/test/` | pytest | no | + +## Prerequisites + +- **JDK 17+.** Android Studio's bundled runtime works: + `export JAVA_HOME="/path/to/Android Studio/jbr"` +- **Android SDK**, with `local.properties` containing `sdk.dir=...`, or `ANDROID_HOME` set. +- **Python 3** on `PATH`. The build shells out to it at configuration time to + generate the stub `unicorn` wheel (see `scripts/build_unicorn_stub_wheel.py`). + If your interpreter is named something unusual, pass `-PpythonExecutable=...`. + +## Run everything + +```bash +export JAVA_HOME="/path/to/jbr" + +./gradlew testDebugUnitTest # JVM unit tests +./gradlew connectedDebugAndroidTest # needs a running emulator/device +python -m pytest app/src/test/python # Chaquopy bridge +(cd python && python -m pytest ./test) # desktop wizard +``` + +--- + +## Android unit tests + +Plain JVM tests. No Android framework, no device. + +```bash +./gradlew testDebugUnitTest +./gradlew test # all variants + +# a single class +./gradlew testDebugUnitTest --tests 'dev.wander.android.opentagviewer.ExampleUnitTest' +``` + +Report: `app/build/reports/tests/testDebugUnitTest/index.html` + +## Android instrumented tests + +These need a real Android runtime — they cover the Room database, the v1→v2 +migration, and the `accessory_json` backfill, none of which can run on the JVM. + +Start an emulator first (or plug in a device with USB debugging on): + +```bash +# one-time: create an AVD +"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \ + -n otv_test -k "system-images;android-35;google_apis_playstore;x86_64" -d pixel_6 + +# boot it +"$ANDROID_HOME/emulator/emulator" -avd otv_test -no-window -gpu swiftshader_indirect & + +adb devices # confirm it appears +``` + +Then: + +```bash +./gradlew connectedDebugAndroidTest + +# example: one test +./gradlew connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabaseMigrationTest + +# example: everything under the db package +./gradlew connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.package=dev.wander.android.opentagviewer.db +``` + +Report: `app/build/reports/androidTests/connected/index.html` + +> **With more than one device attached**, pin the target so a build never lands on +> your phone by accident: +> ```bash +> export ANDROID_SERIAL=emulator-5554 +> ``` +> Debug builds use the applicationId `dev.wander.android.opentagviewer.debug` and are +> labelled "OpenTagViewer (debug)", so they install alongside a real install rather +> than colliding with it. Never uninstall a production install to force an install — +> `allowBackup` is false, so the beacons and location history are gone for good. + +## Chaquopy bridge tests + +Cover `app/src/main/python/main.py`, the module Chaquopy packages into the APK. +Nothing in it imports Android or Java types, so it runs on plain CPython. + +```bash +python -m venv .venv +.venv/bin/pip install -r app/src/test/python/requirements.txt # Windows: .venv/Scripts/pip + +python -m pytest app/src/test/python -v +python -m pytest app/src/test/python -k alignment # one area +``` + +The pins in `app/src/test/python/requirements.txt` must match the +`chaquopy { pip { ... } }` block in `app/build.gradle.kts`, or the tests exercise a +different FindMy version than the app ships. `test_pinned_versions_match_the_app_build` +enforces that. + +Fixtures live in `app/src/test/resources/` — a redacted copy of a real export. See +the README there for what was redacted and why. + +## Desktop wizard tests + +For the macOS export wizard under `python/`. + +```bash +cd python +python -m pip install -r requirements.txt +python -m pip install flake8 pytest + +python -m pytest ./test +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +``` + +--- + +## Offline diagnostic scripts + +Not tests, but useful and requiring neither an Apple account nor network access. + +```bash +# Would my existing export survive the FindMy 0.9.x upgrade, and how expensive +# is the first fetch? Prints derived numbers only - no identifiers, no contents. +python scripts/check_export_compatibility.py ~/path/to/OpenTagViewer_export_.../ + +# Same measurement across a directory of plists. +python scripts/measure_key_alignment_cost.py

--hours-back 24 + +# Generate a synthetic beacon plist of a given age. +python scripts/make_test_beacon_plist.py out.plist --days-old 730 +``` + +## What CI runs + +- `build-debug.yml` — JVM unit tests, Chaquopy bridge tests, instrumented tests + on an emulator, then the debug APK. +- `build-release.yml` — JVM unit tests, then the release APK. +- `macos-scripts-python.yml` — desktop wizard tests across Python 3.10–3.13. +- `macos-exporter-python.yml` — desktop wizard tests plus the PyInstaller build. + +The instrumented job needs KVM on the runner; the workflow enables it before +starting the emulator. \ No newline at end of file From 698119e91065518b6d6f45b9ceb0bca218ac3fb7 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 15:13:06 +0200 Subject: [PATCH 11/50] Fix package assertion for the suffixed debug applicationId, drop stale CI note ExampleInstrumentedTest hard-coded the release package name, so it started failing once debug builds gained an applicationIdSuffix. It now asserts against BuildConfig.APPLICATION_ID and checks the base id as a prefix, which holds for both build types. Also drops the README line about testing and CI not following best practices - all four suites now run in CI and are documented in TESTING.md. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 - .../android/opentagviewer/ExampleInstrumentedTest.java | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 06884206..22b6d6f5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ See [📖 wiki](https://github.com/parawanderer/OpenTagViewer/wiki) for more det Contributions/MRs are more than welcome. Plenty of things can be improved in this project as it was mostly a "hackathony" thing I tried to throw together ASAP and make presentable for layusers. -Plenty of best practices haven't been followed, e.g. in the Testing & CI/CD setup. 📋 **[TESTING.md](./TESTING.md)** lists how to run every test suite in the repo — the Android unit and instrumented tests, the Chaquopy bridge tests, and the desktop wizard tests — plus what diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java index 6be58d1c..223bfbea 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java @@ -24,6 +24,9 @@ public class ExampleInstrumentedTest { public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("dev.wander.android.opentagviewer", appContext.getPackageName()); + // Debug builds carry an applicationIdSuffix so they can install alongside a real + // one, so the package name is only the base id in a release build. + assertEquals(BuildConfig.APPLICATION_ID, appContext.getPackageName()); + assertTrue(appContext.getPackageName().startsWith("dev.wander.android.opentagviewer")); } } \ No newline at end of file From 322fd415b5ebd718fce04d77d039c500250620b6 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 15:50:06 +0200 Subject: [PATCH 12/50] Fail fast on unsupported Anisette providers, keep re-login on server change Two anisette changes. Remote is the only provider that works on Android: local needs the unicorn CPU emulator to run Apple's ADI blob, which Chaquopy cannot build, which is why the stub package exists. Previously an account carrying aniLocal state would restore fine and then blow up later with a NotImplementedError raised from inside the stub, several layers down in anisette, at whatever moment the provider was first exercised. getAccount now inspects the serialized provider type up front and refuses with a readable reason. The fork blocked changing the Anisette server entirely while signed in, on the grounds that 0.9.x embeds the URL in the account state. Rewriting that embedded provider is technically easy - it is just {"type": "aniRemote", "url": ...} - but it would be wrong: anisette supplies a machine identity derived from that server's own ADI provisioning, and Apple binds the session to it, so a session established through one server is not valid when presented with another's. The app would keep running while silently failing auth. Restores main's behaviour instead: the change is allowed and triggers a re-login, which the dialog already warns about. Co-Authored-By: Claude Opus 5 (1M context) --- .../opentagviewer/SettingsActivity.java | 22 ++++------ app/src/main/python/main.py | 41 +++++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - app/src/test/python/test_main.py | 40 ++++++++++++++++++ 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 187e933a..925a2541 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -317,19 +317,6 @@ private void onClickEditLanguage() { } private void onClickEditAnisetteServerUrl() { - // FindMy 0.9.x embeds the anisette URL inside the saved account JSON and - // refuses to swap providers post-login. Block changes while a session - // exists; the user can sign out below to change provider. - var existingAuth = this.authRepository.getUserAuth().blockingFirst(); - if (existingAuth.isPresent()) { - Toast.makeText( - this, - R.string.anisette_change_requires_logout, - Toast.LENGTH_LONG - ).show(); - return; - } - View view = inflate(this, R.layout.anisette_server_url_input_dialog, null); CircularProgressIndicatorSpec spec = new CircularProgressIndicatorSpec(view.getContext(), /* attrs= */ null, 0, com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall); @@ -433,7 +420,14 @@ private void handleAnisetteUrlChangeSave(final String validNewAnisetteUrl) { var finalUrl = Optional.ofNullable(this.currentSettings.getAnisetteServerUrl()); if (!originalUrl.equals(finalUrl)) { - // we need to force a re-login, unfortunately + // A re-login is genuinely required, not just a limitation of how the account + // is serialized. Anisette supplies a machine identity (X-Apple-I-MD-M and + // friends) derived from that server's own ADI provisioning, and Apple binds + // the session to it, so a session established via one server is not valid + // when presented with another server's identity. Rewriting the stored + // provider would keep the app running but leave it failing auth against + // Apple, which is worse than an honest re-login. The dialog warns about this + // up front (see anisette_url_change_warning). this.performLogout(); } } diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 8f24fb22..78e889a7 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -183,6 +183,42 @@ def exportToString(account: AppleAccount) -> str: return json.dumps(account.to_json()) +# The only anisette provider that works on Android. `aniLocal` needs the unicorn CPU +# emulator to run Apple's ADI blob, and Chaquopy cannot build unicorn's native code - +# which is why app/stubs/unicorn exists at all. +SUPPORTED_ANISETTE_TYPE = "aniRemote" + + +def assertAnisetteIsSupported(serializedAccountData: str) -> str: + """ + Check a stored account uses a provider we can actually run, before anything tries + to use it. + + Without this the failure surfaces as a NotImplementedError raised from inside the + stub `unicorn` package, several layers down in anisette, at whatever unlucky moment + the provider is first exercised. Checking the serialized state up front turns that + into a clear message at the app boundary. + + :returns: None if supported, otherwise a human-readable reason. + """ + try: + data = json.loads(serializedAccountData) + anisette_type = (data.get("anisette") or {}).get("type") + + if anisette_type == SUPPORTED_ANISETTE_TYPE: + return None + if anisette_type is None: + return "This saved login has no Anisette configuration and cannot be restored." + return ( + f"This saved login uses an unsupported Anisette provider ({anisette_type}). " + "OpenTagViewer supports remote Anisette servers only on Android - local " + "Anisette needs a CPU emulator that cannot be built for this platform." + ) + except Exception: + print(f"Could not inspect anisette configuration: {traceback.format_exc()}") + return "This saved login could not be read." + + def getAccount(serializedAccountData: str, anisetteServerUrl: str = None) -> AppleAccount: """ Restore an AppleAccount via FindMy 0.9.x's `from_json`. The anisette provider is rebuilt @@ -191,6 +227,11 @@ def getAccount(serializedAccountData: str, anisetteServerUrl: str = None) -> App in Phase 2 when the Java bridge is updated. """ try: + unsupported = assertAnisetteIsSupported(serializedAccountData) + if unsupported: + print(f"Refusing to restore account: {unsupported}") + return None + data = json.loads(serializedAccountData) acc = AppleAccount.from_json(data) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6a4ecf88..4101234e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -98,5 +98,4 @@ Google 地图 高德地图 (AMap) FindMy 库已更新,请重新登录。 - 请先退出登录,再更换 Anisette 服务器。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ccc18d4e..76598014 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -98,5 +98,4 @@ Google 地圖 高德地圖 (AMap) FindMy 函式庫已更新,請重新登入。 - 請先登出,再更換 Anisette 伺服器。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae616d76..ec42cd70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,5 +131,4 @@ Google Maps 高德地图 (AMap) Please sign in again — the FindMy library has been updated. - Sign out first to change your Anisette server. diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 11ad187e..cc8e16e0 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -227,6 +227,46 @@ def fetch_location(self, accessory): assert main._narrowAlignmentIfNeeded(ExplodingAccount(), accessory, now - timedelta(hours=24), now) is False +# -------------------------------------------------------------------------- +# assertAnisetteIsSupported +# +# Remote is the only provider that works on Android; local needs the unicorn CPU +# emulator, which Chaquopy cannot build. Catching that here means a clear message +# instead of a NotImplementedError from inside the stub package. +# -------------------------------------------------------------------------- + +def _account_blob(anisette): + return json.dumps({"type": "account", "anisette": anisette}) + + +def test_remote_anisette_is_supported(): + blob = _account_blob({"type": "aniRemote", "url": "https://ani.example.com"}) + assert main.assertAnisetteIsSupported(blob) is None + + +def test_local_anisette_is_rejected_with_a_readable_reason(): + blob = _account_blob({"type": "aniLocal"}) + reason = main.assertAnisetteIsSupported(blob) + + assert reason is not None + assert "aniLocal" in reason + assert "remote" in reason.lower() + + +def test_missing_anisette_config_is_rejected(): + reason = main.assertAnisetteIsSupported(json.dumps({"type": "account"})) + assert reason is not None + + +def test_unreadable_account_blob_is_rejected(): + assert main.assertAnisetteIsSupported("not json") is not None + + +def test_getAccount_refuses_local_anisette(): + """The guard must actually be wired into the restore path.""" + assert main.getAccount(_account_blob({"type": "aniLocal"})) is None + + # -------------------------------------------------------------------------- # Guard against the test environment drifting from what the app ships # -------------------------------------------------------------------------- From 9c6b2ec92fda76b09d2b07d5f8933641af0f21f7 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 15:50:06 +0200 Subject: [PATCH 13/50] Ship the unicorn stub as source, fix a Windows build hang, add test wrappers The stub was checked in as a prebuilt .whl. A binary artifact that claims the identity of a well-known dependency is hard to audit and easy to mistake for the real thing, so the sources now live in app/stubs/unicorn/ and the wheel is generated into the build directory. Generating it during Gradle configuration meant it ran on every invocation including every IDE sync, and the interpreter probe tried "python3" first, which on Windows resolves to a zero-byte Microsoft Store App Execution Alias that hangs indefinitely rather than failing. Builds and syncs appeared to hang forever on any Windows machine. It is now a task with declared inputs and outputs, and candidates are ordered per platform with alias stubs rejected by inspecting the file instead of executing it. Also adds testAll and testAllOnDevice, wrapping all four suites across three runners, and TESTING.md documenting how to run each. Co-Authored-By: Claude Opus 5 (1M context) --- TESTING.md | 29 ++++- app/build.gradle.kts | 66 ++++++++++- app/libs/unicorn-2.1.1-py3-none-any.whl | Bin 2062 -> 0 bytes app/stubs/unicorn/unicorn/__init__.py | 43 ++++++++ app/stubs/unicorn/unicorn/arm64_const.py | 13 +++ app/stubs/unicorn/unicorn/unicorn.py | 42 +++++++ build.gradle.kts | 122 ++++++++++++++++++++- scripts/build_unicorn_stub_wheel.py | 116 ++++++++++++++++++++ scripts/rebuild_unicorn_stub_wheel.py | 134 ----------------------- scripts/validate_stub_wheels.py | 81 -------------- 10 files changed, 423 insertions(+), 223 deletions(-) delete mode 100644 app/libs/unicorn-2.1.1-py3-none-any.whl create mode 100644 app/stubs/unicorn/unicorn/__init__.py create mode 100644 app/stubs/unicorn/unicorn/arm64_const.py create mode 100644 app/stubs/unicorn/unicorn/unicorn.py create mode 100644 scripts/build_unicorn_stub_wheel.py delete mode 100644 scripts/rebuild_unicorn_stub_wheel.py delete mode 100644 scripts/validate_stub_wheels.py diff --git a/TESTING.md b/TESTING.md index b28e4fea..1ec506dd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -22,15 +22,36 @@ the desktop for the export wizard). Each needs a different runner. ## Run everything +Two aggregate tasks wrap all four suites: + ```bash export JAVA_HOME="/path/to/jbr" -./gradlew testDebugUnitTest # JVM unit tests -./gradlew connectedDebugAndroidTest # needs a running emulator/device -python -m pytest app/src/test/python # Chaquopy bridge -(cd python && python -m pytest ./test) # desktop wizard +./gradlew testAll # everything that needs no device +./gradlew testAllOnDevice # the above, plus instrumented tests (emulator/device required) ``` +`testAll` is deliberately separate so you don't get a failure just for not having an +emulator booted. It will tell you the instrumented tests were skipped. + +Both create a virtualenv under `app/build/test-venv` on first run and install the +Python dependencies into it, so a fresh clone works without any manual setup. The +first run is slow; afterwards it's reused. + +If your Python isn't discovered automatically: + +```bash +./gradlew testAll -PpythonExecutable=/path/to/python +``` + +> On Windows, `python3.exe` in `%LOCALAPPDATA%\Microsoft\WindowsApps\` is a zero-byte +> Microsoft Store alias that **hangs** rather than failing when run non-interactively. +> The build skips those deliberately and prefers a real interpreter; if you hit a +> mysterious hang in any Python-invoking tooling on Windows, that alias is a good first +> suspect. + +The individual commands are below if you want to run one suite directly. + --- ## Android unit tests diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5af5aa00..b8c3fe04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,14 +121,74 @@ lombok { version = libs.versions.lombokVersion.get() } +// FindMy >= 0.9 depends on anisette, which depends on unicorn (a CPU emulator used only +// for *local* Anisette). Chaquopy cannot build unicorn's native code for Android, so the +// whole dependency tree fails to resolve without a stand-in. We build a pure-Python stub +// wheel from the real sources in app/stubs/unicorn/ rather than checking in a prebuilt +// .whl - a binary artifact impersonating a well-known dependency is hard to audit. +val unicornStubWheel = layout.buildDirectory.file( + "generated/stub-wheels/unicorn-2.1.1-py3-none-any.whl" +) + +/** + * Locate a usable Python 3 interpreter. + * + * On Windows, `python3` is usually a zero-byte Microsoft Store "App Execution Alias" that + * hangs indefinitely when run non-interactively instead of failing, so candidates are + * ordered per-platform and stub aliases are filtered out by inspecting the file rather + * than by executing it. + */ +fun resolvePythonExecutable(): String { + providers.gradleProperty("pythonExecutable").orNull?.let { return it } + + val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val names = if (isWindows) listOf("python.exe", "python3.exe") else listOf("python3", "python") + + val pathDirs = (System.getenv("PATH") ?: "").split(File.pathSeparator).filter { it.isNotBlank() } + for (name in names) { + for (dir in pathDirs) { + val candidate = File(dir, name) + if (!candidate.isFile || !candidate.canExecute()) continue + // Store aliases are zero-length reparse points; executing one blocks forever. + if (candidate.length() == 0L) continue + if (candidate.absolutePath.contains("WindowsApps", ignoreCase = true)) continue + return candidate.absolutePath + } + } + + throw GradleException( + "No usable Python 3 interpreter found on PATH (looked for ${names.joinToString(", ")}). " + + "Chaquopy needs one to build the unicorn stub wheel. " + + "Install Python 3 or pass -PpythonExecutable=/path/to/python." + ) +} + +// A real task rather than configuration-time work: this used to run on every Gradle +// invocation, including every IDE sync, which blocked them. +val generateUnicornStubWheel by tasks.registering(Exec::class) { + group = "build" + description = "Builds the pure-Python unicorn stub wheel that lets FindMy 0.9.x resolve." + + val script = rootProject.file("scripts/build_unicorn_stub_wheel.py") + + inputs.dir(layout.projectDirectory.dir("stubs/unicorn")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(script).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.file(unicornStubWheel) + outputs.cacheIf { true } + + commandLine(resolvePythonExecutable(), script.absolutePath, unicornStubWheel.get().asFile.absolutePath) +} + +// Chaquopy installs from the wheel path, so it must exist before pip runs. +tasks.matching { it.name.contains("PythonRequirements") || it.name.contains("PythonReqs") } + .configureEach { dependsOn(generateUnicornStubWheel) } + chaquopy { defaultConfig { version = "3.12" pip { // SEE: https://chaquo.com/chaquopy/doc/current/android.html#android-requirements - // Stub wheel satisfies anisette's unicorn>=2.1.1 dependency without native compilation. - // We only use RemoteAnisetteProvider so unicorn is never actually instantiated. - install("libs/unicorn-2.1.1-py3-none-any.whl") + install(unicornStubWheel.get().asFile.absolutePath) install("FindMy==0.9.8") install("NSKeyedUnArchiver==1.5") } diff --git a/app/libs/unicorn-2.1.1-py3-none-any.whl b/app/libs/unicorn-2.1.1-py3-none-any.whl deleted file mode 100644 index 778bd1a36a4342a429ede819548a4847a4fc83dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2062 zcmWIWW@Zs#U|`^2SWq-G=4|uz^Ai{u7|NI#7=(eMrFogj`9*p9@$s2?nI-Y@dIgoW zlMZIxb`Wv>{#jJK`f`(D=i{!HW89%(g`T03SM!*ccD#IXR7&gYjEg7KcGtP=Gl&<` z@$QmQjotVA+}=Gq&j{Sl{mH&`l9P0+u6X^=whuqvGqP^qv}1C}fn_Y~QXH=d3Ei6H z<f0%ayCrX0g}il_AnMrbK&$Myy-&WE$6+qraJ#XSlPq?eNfK z+-|URlhJa+4^=iU;7!&^47JiN1BH~npmp{2@$U%xU| zZP8kMNl)vs5>I}T{+!>vHTPD{-`95Utnu9wjq*n(zCZW)>-Gb&dVedMef%fPzc_vB z{baj22J`<;-TuPx+(*~-X)&g&=am*6yD|5o<*~E=6Mu$DcW+J5>8qS})G+PGQHF#I znWY9+l5;b|ckC@;TYT-(mKfpElXqV}$oshM_NUr z>^#`)Y!mebM%-Z_7DI}_#G+g?llbKPyy6mI9CDZTmGgWuHSXjuD(x@l z4%iTC@Px}%>@#N&kmV%G&9>6mO_bZ*ge}wTuz(pO_v{7SvzuCd16!4O92QMu%UINw z5NOiwD$(fLyMqDM0V1yZEwq3sC=Qr{gpeEnO-Vq@=U#Hmb~5B)c(8lFQbmx)qUDVr z_~goNxTZYVdBx1GJNL6k(D84tk6(VdO#OZlw@LgYq4{$-a{d;vFbEXzUpppr^;T!X zE$MrOIjF~VXx10qa!zBhqaRNB*zB}@nH=N&pafRJ>DC%0s}-9 zxkQ2{+$ufN#kk_YDwbsuX$Je+w>5p}n<=vXsr0g7o)ZGy8oLaBubt7CcRJ;a+3t6{ zyg7Q5*i`BkHu70|fBtkP?)xKl_oME{7Nv(Z-NfaVwk*AR*r0pkUg_26lMM=rRz50t zbv{BoQ=(A8DoT9a5zDLR`FJa)Upn`5aii9WZU48V`Lgo9+0eT*QsRKntjhX|w~J=- zzwX`{6?pvD+Da z(lZ3Yl+5B1-ORkSe0^Wn5Jwls5J%sWw%msd1lS(f?tdg(>akFEb7!;n)TS-JXUY^M zyi<^8{lB-_d;Zs*#fx2f{~TM|m3hFV=bhtB=3QP}7QKr&{9>MMy4L-Mum5-GXeuN3O#P0<0JIF8{zJ9JK2*)56^XPP;{X?bj+8Czf#B+xO+|{ny+fRZh`@ zeE-bO<}o@4i}S`!TzjS5M6X@r_%+?*uj7{$@9Ndr;x=3wcBHakYa(LVpu!4>IQ4+iYK?w1 z;XuI<0Va)_zhd&*LYl$T# zXf6|PEe~)Ls;`t~igu{v=4ZwiMTDzbdfL@+K zv@= 0.9 depends on `anisette`, which depends on `unicorn` (a CPU emulator +# used to run Apple's ADI blob for *local* Anisette). Chaquopy cannot build unicorn's +# native code for Android, so the whole dependency tree fails to resolve without this. +# +# OpenTagViewer only ever uses RemoteAnisetteProvider, so the local emulator - the only +# thing that instantiates Uc - is never reached at runtime. See AnisetteProviderGuard on +# the Java side, which fails fast with a legible message rather than letting anisette +# reach this stub. +# +# This package is built into a wheel at Gradle configuration time; see +# scripts/build_unicorn_stub_wheel.py. It is deliberately NOT checked in as a +# prebuilt .whl - a binary artifact that spoofs a real dependency's identity is a +# landmine for anyone reading the repo. + +UC_ARCH_ARM = 1 +UC_ARCH_ARM64 = 2 +UC_ARCH_X86 = 4 + +UC_MODE_ARM = 0 +UC_MODE_32 = 1 << 2 +UC_MODE_64 = 1 << 3 + +UC_HOOK_CODE = 1 << 1 +UC_HOOK_BLOCK = 1 << 3 +UC_HOOK_MEM_READ_UNMAPPED = 1 << 4 +UC_HOOK_MEM_WRITE_UNMAPPED = 1 << 5 +UC_HOOK_MEM_FETCH_UNMAPPED = 1 << 6 + +UC_MEM_WRITE_UNMAPPED = 19 +UC_MEM_FETCH_UNMAPPED = 20 + +from .unicorn import Uc + +__all__ = [ + "UC_ARCH_ARM", "UC_ARCH_ARM64", "UC_ARCH_X86", + "UC_MODE_ARM", "UC_MODE_32", "UC_MODE_64", + "UC_HOOK_CODE", "UC_HOOK_BLOCK", + "UC_HOOK_MEM_READ_UNMAPPED", "UC_HOOK_MEM_WRITE_UNMAPPED", "UC_HOOK_MEM_FETCH_UNMAPPED", + "UC_MEM_WRITE_UNMAPPED", "UC_MEM_FETCH_UNMAPPED", + "Uc", +] diff --git a/app/stubs/unicorn/unicorn/arm64_const.py b/app/stubs/unicorn/unicorn/arm64_const.py new file mode 100644 index 00000000..82330569 --- /dev/null +++ b/app/stubs/unicorn/unicorn/arm64_const.py @@ -0,0 +1,13 @@ +# Register constants imported by anisette's ADI hooks (anisette/_hooks.py). +# Only the subset anisette actually references is defined; the values match +# upstream unicorn so that any accidental use is at least not silently wrong. + +UC_ARM64_REG_FP = 1 +UC_ARM64_REG_LR = 2 +UC_ARM64_REG_SP = 3 +UC_ARM64_REG_W13 = 163 +UC_ARM64_REG_W14 = 164 +UC_ARM64_REG_W15 = 165 +UC_ARM64_REG_X0 = 190 +UC_ARM64_REG_X1 = 191 +UC_ARM64_REG_X2 = 192 diff --git a/app/stubs/unicorn/unicorn/unicorn.py b/app/stubs/unicorn/unicorn/unicorn.py new file mode 100644 index 00000000..12f057ac --- /dev/null +++ b/app/stubs/unicorn/unicorn/unicorn.py @@ -0,0 +1,42 @@ +"""The emulator surface anisette expects, stubbed out. + +Every method raises. If any of these are ever reached it means something tried to +use *local* Anisette on Android, which is not supported - the app should have +stopped that at the boundary (see AnisetteProviderGuard on the Java side). +""" + +_UNSUPPORTED = ( + "unicorn stub: local Anisette is not supported on Android. " + "OpenTagViewer uses RemoteAnisetteProvider only. If you are seeing this, an " + "anisette provider was constructed from local (aniLocal) state - check " + "AnisetteProviderGuard." +) + + +class Uc: + def __init__(self, arch, mode): + raise NotImplementedError(_UNSUPPORTED) + + def mem_map(self, address, size, perms=7): + raise NotImplementedError(_UNSUPPORTED) + + def mem_write(self, address, data): + raise NotImplementedError(_UNSUPPORTED) + + def mem_read(self, address, length): + raise NotImplementedError(_UNSUPPORTED) + + def reg_write(self, reg_id, value): + raise NotImplementedError(_UNSUPPORTED) + + def reg_read(self, reg_id): + raise NotImplementedError(_UNSUPPORTED) + + def hook_add(self, hook_type, callback, user_data=None, begin=1, end=0, arg1=0): + raise NotImplementedError(_UNSUPPORTED) + + def emu_start(self, begin, until, timeout=0, count=0): + raise NotImplementedError(_UNSUPPORTED) + + def emu_stop(self): + raise NotImplementedError(_UNSUPPORTED) diff --git a/build.gradle.kts b/build.gradle.kts index e5da10da..967c0417 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,4 +13,124 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.google.android.libraries.mapsplatform.secrets.gradle.plugin) apply false alias(libs.plugins.chaquopy) apply false -} \ No newline at end of file +} +// --------------------------------------------------------------------------- +// Aggregate test tasks +// +// Tests live in four places across three runners (see TESTING.md). These wrap them +// so there is one command to run everything, rather than four to remember. +// --------------------------------------------------------------------------- + +val testVenvDir = layout.buildDirectory.dir("test-venv") + +/** + * Locate a usable Python 3 interpreter. + * + * On Windows `python3` is normally a zero-byte Microsoft Store "App Execution Alias" + * that hangs forever when run non-interactively rather than failing, so candidates are + * ordered per-platform and alias stubs are rejected by inspecting the file instead of + * executing it. + */ +fun findPython(): String { + providers.gradleProperty("pythonExecutable").orNull?.let { return it } + + val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val names = if (isWindows) listOf("python.exe", "python3.exe") else listOf("python3", "python") + + for (name in names) { + for (dir in (System.getenv("PATH") ?: "").split(File.pathSeparator).filter { it.isNotBlank() }) { + val candidate = File(dir, name) + if (!candidate.isFile || !candidate.canExecute()) continue + if (candidate.length() == 0L) continue + if (candidate.absolutePath.contains("WindowsApps", ignoreCase = true)) continue + return candidate.absolutePath + } + } + throw GradleException( + "No usable Python 3 interpreter found on PATH. Install Python 3 or pass " + + "-PpythonExecutable=/path/to/python." + ) +} + +fun venvBin(name: String): File { + val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val binDir = if (isWindows) "Scripts" else "bin" + val exe = if (isWindows) "$name.exe" else name + return testVenvDir.get().file("$binDir/$exe").asFile +} + +val bridgeRequirements = file("app/src/test/python/requirements.txt") +val wizardRequirements = file("python/requirements.txt") + +// Created once and reused, so a fresh clone can run the tests with no manual setup. +val setupTestVenv by tasks.registering { + group = "verification" + description = "Creates the shared virtualenv used by the pytest suites." + + inputs.file(bridgeRequirements) + inputs.file(wizardRequirements) + outputs.dir(testVenvDir) + + doLast { + val python = findPython() + val venv = testVenvDir.get().asFile + + if (!venvBin("python").isFile) { + venv.parentFile.mkdirs() + providers.exec { + commandLine(python, "-m", "venv", venv.absolutePath) + }.result.get().assertNormalExitValue() + } + + val pip = venvBin("python").absolutePath + listOf(bridgeRequirements, wizardRequirements).forEach { requirements -> + logger.lifecycle("Installing ${requirements.name} into the test venv...") + providers.exec { + commandLine(pip, "-m", "pip", "install", "-q", "-r", requirements.absolutePath) + }.result.get().assertNormalExitValue() + } + providers.exec { + commandLine(pip, "-m", "pip", "install", "-q", "pytest") + }.result.get().assertNormalExitValue() + } +} + +// The venv path is deterministic, so the command can be built up front - the interpreter +// only has to exist by the time the task actually runs, which setupTestVenv guarantees. +val pytestChaquopyBridge by tasks.registering(Exec::class) { + group = "verification" + description = "Runs the pytest suite for app/src/main/python (the Chaquopy bridge)." + dependsOn(setupTestVenv) + commandLine(venvBin("python").absolutePath, "-m", "pytest", "app/src/test/python", "-v") +} + +val pytestDesktopWizard by tasks.registering(Exec::class) { + group = "verification" + description = "Runs the pytest suite for the macOS export wizard under python/." + dependsOn(setupTestVenv) + workingDir = file("python") + commandLine(venvBin("python").absolutePath, "-m", "pytest", "./test", "-v") +} + +val testAll by tasks.registering { + group = "verification" + description = "Runs every test suite that does not need a device or emulator." + dependsOn(":app:testDebugUnitTest", pytestChaquopyBridge, pytestDesktopWizard) + + doLast { + logger.lifecycle("") + logger.lifecycle("All device-free suites passed.") + logger.lifecycle("Instrumented tests were NOT run - use ./gradlew testAllOnDevice with an emulator booted.") + } +} + +val testAllOnDevice by tasks.registering { + group = "verification" + description = "Runs every test suite, including instrumented tests (needs a device or emulator)." + dependsOn(testAll, ":app:connectedDebugAndroidTest") + + doLast { + logger.lifecycle("") + logger.lifecycle("All suites passed, including instrumented tests.") + } +} diff --git a/scripts/build_unicorn_stub_wheel.py b/scripts/build_unicorn_stub_wheel.py new file mode 100644 index 00000000..010cec2e --- /dev/null +++ b/scripts/build_unicorn_stub_wheel.py @@ -0,0 +1,116 @@ +"""Build the stub `unicorn` wheel from source. + +FindMy.py >= 0.9 pulls in `anisette`, which requires `unicorn` (a CPU emulator used +only for *local* Anisette). Chaquopy cannot build unicorn's native code for Android, +so dependency resolution fails outright. This produces a pure-Python wheel exposing +just the surface anisette imports, which lets the rest of 0.9.x install. + +The wheel is a *build artifact*, generated at Gradle configuration time from the real +source files in app/stubs/unicorn/. It is deliberately not checked in: a prebuilt +binary that claims to be a well-known dependency is hard to audit and easy to +mistake for the real thing. + +Usage: + python scripts/build_unicorn_stub_wheel.py +""" + +from __future__ import annotations + +import base64 +import csv +import hashlib +import sys +import zipfile +from io import StringIO +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE_DIR = REPO_ROOT / "app" / "stubs" / "unicorn" + +NAME = "unicorn" +# Must satisfy anisette's `unicorn>=2.1.1` constraint. +VERSION = "2.1.1" +DIST_INFO = f"{NAME}-{VERSION}.dist-info" + +METADATA = f"""Metadata-Version: 2.1 +Name: {NAME} +Version: {VERSION} +Summary: Unicorn CPU emulator engine - Android stub, no native code, raises on use +License: GPL-2 +""" + +WHEEL_METADATA = """Wheel-Version: 1.0 +Generator: build_unicorn_stub_wheel.py +Root-Is-Purelib: true +Tag: py3-none-any +""" + + +def _record_row(path: str, data: bytes) -> tuple[str, str, str]: + digest = hashlib.sha256(data).digest() + digest_b64 = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return path, f"sha256={digest_b64}", str(len(data)) + + +def _build_record(rows: list[tuple[str, str, str]]) -> bytes: + output = StringIO() + writer = csv.writer(output, lineterminator="\n") + for row in rows: + writer.writerow(row) + writer.writerow((f"{DIST_INFO}/RECORD", "", "")) + return output.getvalue().encode("utf-8") + + +def _collect_sources() -> dict[str, bytes]: + if not SOURCE_DIR.is_dir(): + msg = f"stub source directory not found: {SOURCE_DIR}" + raise FileNotFoundError(msg) + + sources: dict[str, bytes] = {} + for path in sorted(SOURCE_DIR.rglob("*.py")): + arcname = path.relative_to(SOURCE_DIR).as_posix() + sources[arcname] = path.read_bytes() + + if not sources: + msg = f"no .py files found under {SOURCE_DIR}" + raise FileNotFoundError(msg) + return sources + + +def build(output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + + entries = _collect_sources() + entries[f"{DIST_INFO}/METADATA"] = METADATA.encode("utf-8") + entries[f"{DIST_INFO}/WHEEL"] = WHEEL_METADATA.encode("utf-8") + + rows: list[tuple[str, str, str]] = [] + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for arcname, data in entries.items(): + # Fixed timestamp so the wheel is byte-for-byte reproducible; otherwise + # Gradle sees a "changed" input on every configure and re-runs pip. + info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + zf.writestr(info, data) + rows.append(_record_row(arcname, data)) + + record = zipfile.ZipInfo(f"{DIST_INFO}/RECORD", date_time=(1980, 1, 1, 0, 0, 0)) + record.compress_type = zipfile.ZIP_DEFLATED + record.external_attr = 0o644 << 16 + zf.writestr(record, _build_record(rows)) + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + + output = Path(argv[1]) + build(output) + print(f"Built stub wheel: {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/rebuild_unicorn_stub_wheel.py b/scripts/rebuild_unicorn_stub_wheel.py deleted file mode 100644 index 5515e38e..00000000 --- a/scripts/rebuild_unicorn_stub_wheel.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import base64 -import csv -import hashlib -from io import StringIO -from pathlib import Path -import zipfile - - -WHEEL_PATH = Path("app/libs/unicorn-2.1.1-py3-none-any.whl") - -FILES = { - "unicorn/__init__.py": """# Stub unicorn package — satisfies the anisette dependency without native code. -# OpenTagViewer only uses RemoteAnisetteProvider; the local Anisette emulator -# (which is the only thing that instantiates Uc) is never reached at runtime. - -UC_ARCH_ARM = 1 -UC_ARCH_ARM64 = 2 -UC_ARCH_X86 = 4 - -UC_MODE_ARM = 0 -UC_MODE_32 = 1 << 2 -UC_MODE_64 = 1 << 3 - -UC_HOOK_CODE = 1 << 1 -UC_HOOK_BLOCK = 1 << 3 -UC_HOOK_MEM_READ_UNMAPPED = 1 << 4 -UC_HOOK_MEM_WRITE_UNMAPPED = 1 << 5 -UC_HOOK_MEM_FETCH_UNMAPPED = 1 << 6 - -UC_MEM_WRITE_UNMAPPED = 19 -UC_MEM_FETCH_UNMAPPED = 20 - -from .unicorn import Uc - -__all__ = [ - "UC_ARCH_ARM", "UC_ARCH_ARM64", "UC_ARCH_X86", - "UC_MODE_ARM", "UC_MODE_32", "UC_MODE_64", - "UC_HOOK_CODE", "UC_HOOK_BLOCK", - "UC_HOOK_MEM_READ_UNMAPPED", "UC_HOOK_MEM_WRITE_UNMAPPED", "UC_HOOK_MEM_FETCH_UNMAPPED", - "UC_MEM_WRITE_UNMAPPED", "UC_MEM_FETCH_UNMAPPED", - "Uc", -] -""", - "unicorn/arm64_const.py": """UC_ARM64_REG_FP = 1 -UC_ARM64_REG_LR = 2 -UC_ARM64_REG_SP = 3 -UC_ARM64_REG_W13 = 163 -UC_ARM64_REG_W14 = 164 -UC_ARM64_REG_W15 = 165 -UC_ARM64_REG_X0 = 190 -UC_ARM64_REG_X1 = 191 -UC_ARM64_REG_X2 = 192 -""", - "unicorn/unicorn.py": """class Uc: - def __init__(self, arch, mode): - raise NotImplementedError( - "unicorn stub: local Anisette is not supported on Android. " - "Use RemoteAnisetteProvider instead." - ) - - def mem_map(self, address, size, perms=7): - raise NotImplementedError - - def mem_write(self, address, data): - raise NotImplementedError - - def mem_read(self, address, length): - raise NotImplementedError - - def reg_write(self, reg_id, value): - raise NotImplementedError - - def reg_read(self, reg_id): - raise NotImplementedError - - def hook_add(self, hook_type, callback, user_data=None, begin=1, end=0, arg1=0): - raise NotImplementedError - - def emu_start(self, begin, until, timeout=0, count=0): - raise NotImplementedError - - def emu_stop(self): - raise NotImplementedError -""", - "unicorn-2.1.1.dist-info/METADATA": """Metadata-Version: 2.1 -Name: unicorn -Version: 2.1.1 -Summary: Unicorn CPU emulator engine (Android stub - no native code) -License: GPL-2 -""", - "unicorn-2.1.1.dist-info/WHEEL": """Wheel-Version: 1.0 -Generator: rebuild_unicorn_stub_wheel.py -Root-Is-Purelib: true -Tag: py3-none-any -""", -} - - -def record_line(path: str, data: bytes) -> tuple[str, str, str]: - digest = hashlib.sha256(data).digest() - digest_b64 = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return path, f"sha256={digest_b64}", str(len(data)) - - -def build_record(rows: list[tuple[str, str, str]]) -> bytes: - output = StringIO() - writer = csv.writer(output, lineterminator="\n") - for row in rows: - writer.writerow(row) - writer.writerow(("unicorn-2.1.1.dist-info/RECORD", "", "")) - return output.getvalue().encode("utf-8") - - -def main() -> int: - WHEEL_PATH.parent.mkdir(parents=True, exist_ok=True) - - rows: list[tuple[str, str, str]] = [] - with zipfile.ZipFile(WHEEL_PATH, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for path, text in FILES.items(): - data = text.encode("utf-8") - zf.writestr(path, data) - rows.append(record_line(path, data)) - - record_data = build_record(rows) - zf.writestr("unicorn-2.1.1.dist-info/RECORD", record_data) - - print(f"Rebuilt {WHEEL_PATH}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_stub_wheels.py b/scripts/validate_stub_wheels.py deleted file mode 100644 index 3a634765..00000000 --- a/scripts/validate_stub_wheels.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import argparse -import sys -import zipfile -from pathlib import Path - - -BOM = b"\xef\xbb\xbf" - - -def validate_wheel(path: Path) -> list[str]: - errors: list[str] = [] - - if not path.exists(): - return [f"{path}: file not found"] - - try: - with zipfile.ZipFile(path) as zf: - dist_info_dirs = sorted( - { - name.rsplit("/", 1)[0] - for name in zf.namelist() - if name.endswith(".dist-info/WHEEL") - } - ) - if len(dist_info_dirs) != 1: - return [f"{path}: expected exactly one .dist-info/WHEEL entry, found {len(dist_info_dirs)}"] - - dist_info_dir = dist_info_dirs[0] - wheel_bytes = zf.read(f"{dist_info_dir}/WHEEL") - if wheel_bytes.startswith(BOM): - errors.append(f"{path}: {dist_info_dir}/WHEEL starts with UTF-8 BOM") - - wheel_text = wheel_bytes.decode("utf-8-sig") - if not any(line.startswith("Wheel-Version:") for line in wheel_text.splitlines()): - errors.append(f"{path}: {dist_info_dir}/WHEEL is missing Wheel-Version") - - metadata_bytes = zf.read(f"{dist_info_dir}/METADATA") - if metadata_bytes.startswith(BOM): - errors.append(f"{path}: {dist_info_dir}/METADATA starts with UTF-8 BOM") - - record_bytes = zf.read(f"{dist_info_dir}/RECORD") - if record_bytes.startswith(BOM): - errors.append(f"{path}: {dist_info_dir}/RECORD starts with UTF-8 BOM") - except zipfile.BadZipFile: - return [f"{path}: invalid zip archive"] - except KeyError as exc: - return [f"{path}: missing required wheel entry: {exc}"] - - return errors - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Validate local stub wheels checked into the repository." - ) - parser.add_argument( - "wheels", - nargs="+", - type=Path, - help="Wheel files to validate.", - ) - args = parser.parse_args() - - all_errors: list[str] = [] - for wheel in args.wheels: - all_errors.extend(validate_wheel(wheel)) - - if all_errors: - for error in all_errors: - print(error, file=sys.stderr) - return 1 - - for wheel in args.wheels: - print(f"{wheel}: OK") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From c7b3b873aee990fc060f2d1df4a128b81995735f Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 17:02:13 +0200 Subject: [PATCH 14/50] AMap: require a user-supplied API key instead of bundling one AMap issues Android keys per developer account, bound to a package name and a signing fingerprint, and their terms expect the key holder to be the app's operator. Bundling a key means whoever registered it carries the quota and compliance liability for every install - and registering one at all requires a mainland Chinese account with real-name verification, which the maintainers of this project do not have. A key bound to one signing certificate would not authorise a differently-signed build anyway. So no key ships. Anyone wanting AMap supplies their own in Settings, applied at runtime via MapsInitializer.setApiKey, the same shape as the Anisette server URL. Selecting AMap without a key does not save; the picker routes the user straight to entering one and completes the switch afterwards. Clearing the key falls back to Google Maps rather than leaving a provider selected that can only render blank. Registering a key needs the package name and SHA-1 fingerprint, so the dialog shows both, read from the installed package at runtime, with a button to copy them. That keeps them correct for whichever build is actually in the user's hand rather than a documented value that goes stale when signing changes. Removes AMAP_API_KEY from the manifest, the build defaults and both CI workflows, since nothing reads it now. Translated into all nine supported locales. zh-rCN and zh-rTW matter most here: they are the users this feature exists for, and English fallback would be the wrong default for exactly that audience. Native review is welcome. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-debug.yml | 1 - .github/workflows/build-release.yml | 1 - app/build.gradle.kts | 1 - app/src/main/AndroidManifest.xml | 10 +- .../opentagviewer/OpenAirTagApplication.java | 65 +++++++------ .../opentagviewer/SettingsActivity.java | 74 +++++++++++++++ .../db/datastore/UserSettingsDataStore.java | 1 + .../db/repo/UserSettingsRepository.java | 6 ++ .../db/repo/model/UserSettings.java | 18 +++- .../util/android/SigningInfoUtil.java | 94 +++++++++++++++++++ .../res/layout/amap_api_key_input_dialog.xml | 62 ++++++++++++ app/src/main/res/values-de/strings.xml | 6 ++ app/src/main/res/values-en/strings.xml | 6 ++ app/src/main/res/values-fr/strings.xml | 6 ++ app/src/main/res/values-ja/strings.xml | 6 ++ app/src/main/res/values-ko/strings.xml | 6 ++ app/src/main/res/values-nl/strings.xml | 6 ++ app/src/main/res/values-ru/strings.xml | 6 ++ app/src/main/res/values-zh-rCN/strings.xml | 6 ++ app/src/main/res/values-zh-rTW/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 7 ++ local.defaults.properties | 1 - 22 files changed, 354 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java create mode 100644 app/src/main/res/layout/amap_api_key_input_dialog.xml diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 20cd1499..3c5a260d 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -125,7 +125,6 @@ jobs: - name: Inject secrets.properties run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties - echo "AMAP_API_KEY=${{ secrets.AMAP_API_KEY || 'amap_key_default_value' }}" >> secrets.properties - name: Validate checked-in stub wheels run: python3 scripts/validate_stub_wheels.py app/libs/unicorn-2.1.1-py3-none-any.whl diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index ed4af334..dfb28454 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -75,7 +75,6 @@ jobs: - name: Inject secrets.properties run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}" > secrets.properties - echo "AMAP_API_KEY=${{ secrets.AMAP_API_KEY }}" >> secrets.properties - name: Recreate release keystore run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8c3fe04..e8386a59 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,6 @@ secrets { // 1. If the secrets.properties file does not exist, create it in the same folder as the local.properties file. // 2. Add these lines, where YOUR_API_KEY is your API key: // MAPS_API_KEY=YOUR_API_KEY - // AMAP_API_KEY=YOUR_AMAP_API_KEY // // How to get Google Maps API Key: // - Visit: https://console.cloud.google.com/google/maps-apis/ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 468bb19f..8826f7cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,18 +88,16 @@ 1. Open the root project's secrets.properties file 2. Add this line, where YOUR_API_KEY is your API key: MAPS_API_KEY=YOUR_API_KEY - AMAP_API_KEY=YOUR_AMAP_API_KEY --> - - - + + * Reflection is used throughout so the app still runs when the AMap SDK is absent, + * which is the normal case for anyone using Google Maps. + */ + private void applyUserSuppliedAMapKey() { + try { + var settings = new UserSettingsRepository( + UserSettingsDataStore.getInstance(this.getApplicationContext()) + ).getUserSettings(); + + if (!settings.hasAmapApiKey()) { + // Expected unless the user has chosen AMap and supplied a key. + Log.d(TAG, "No user-supplied AMap API key; skipping AMap initialisation"); + return; + } + + Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); + mapsInitializerClass + .getMethod("setApiKey", String.class) + .invoke(null, settings.getAmapApiKey()); + + Log.i(TAG, "Applied user-supplied AMap API key"); + } catch (ClassNotFoundException e) { + Log.d(TAG, "AMap SDK not present; skipping"); + } catch (Exception e) { + Log.e(TAG, "Failed to apply the user-supplied AMap API key", e); + } + } + /** * 高德地图隐私合规设置 * 根据《个人信息保护法》要求,必须在调用SDK任何接口之前进行隐私合规配置 @@ -31,35 +62,11 @@ public void onCreate() { */ private void initAMapPrivacyCompliance() { try { - // FIX: 尝试读取并修复可能被误解析为Integer的AMap API Key - try { - android.content.pm.ApplicationInfo appInfo = this.getPackageManager().getApplicationInfo(this.getPackageName(), android.content.pm.PackageManager.GET_META_DATA); - if (appInfo.metaData != null) { - Object keyObj = appInfo.metaData.get("com.amap.api.v2.apikey"); - if (keyObj instanceof Integer) { - Log.w(TAG, "AMap API Key was parsed as Integer (" + keyObj + "), converting to String..."); - String keyStr; - try { - // 尝试作为资源ID读取 - keyStr = this.getString((Integer) keyObj); - } catch (Exception e) { - // 否则直接转换为字符串 - keyStr = String.valueOf(keyObj); - } - - Log.i(TAG, "Recovered AMap Key: " + keyStr); - - // 尝试通过反射设置Key - // com.amap.api.maps.MapsInitializer.setApiKey(String) - Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); - java.lang.reflect.Method setApiKeyMethod = mapsInitializerClass.getMethod("setApiKey", String.class); - setApiKeyMethod.invoke(null, keyStr); - Log.i(TAG, "Successfully set AMap API Key manually via reflection"); - } - } - } catch (Exception e) { - Log.e(TAG, "Failed to fix AMap API Key", e); - } + // The app ships no AMap key. Keys are issued per developer account and bound to + // a package name and signing fingerprint, and AMap's terms expect the key holder + // to be the app's operator - so each user supplies their own in Settings, the + // same way the Anisette server URL works. Apply it before any SDK call. + this.applyUserSuppliedAMapKey(); // 使用反射加载高德地图SDK,避免编译时依赖 Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 925a2541..59675fbd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -33,6 +33,7 @@ import com.google.android.material.progressindicator.IndeterminateDrawable; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.textfield.MaterialAutoCompleteTextView; +import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import java.util.ArrayList; @@ -62,6 +63,7 @@ import dev.wander.android.opentagviewer.ui.extensions.AppAutoCompleteTextView; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.LocaleConfigUtil; +import dev.wander.android.opentagviewer.util.android.SigningInfoUtil; import dev.wander.android.opentagviewer.util.validate.AnisetteUrlValidatorUtil; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import lombok.Data; @@ -247,6 +249,18 @@ private void onClickEditMapProvider() { if (checkedItemPosition != AdapterView.INVALID_POSITION) { String selectedProvider = checkedItemPosition == 0 ? "google" : "amap"; Log.d(TAG, "Selected map provider: " + selectedProvider); + + // AMap ships with no key: they are issued per developer account and + // bound to a package name and signing fingerprint, so each user + // brings their own. Selecting it without one would leave a blank map + // and no explanation, so gate the change on having a key and take + // the user straight to entering one. + if ("amap".equals(selectedProvider) && !this.currentSettings.hasAmapApiKey()) { + Toast.makeText(this, R.string.amap_key_required, Toast.LENGTH_LONG).show(); + this.onClickEditAmapApiKey(selectedProvider); + return; + } + this.updateMapProvider(selectedProvider); } }) @@ -256,6 +270,66 @@ private void onClickEditMapProvider() { builder.show(); } + /** + * Prompt for the user's own AMap API key. + * + * @param providerToApplyOnSuccess if non-null, the provider to switch to once a key has + * been supplied. Lets the picker route the user here and + * have their selection complete afterwards, rather than + * silently dropping it. + */ + private void onClickEditAmapApiKey(final String providerToApplyOnSuccess) { + View view = inflate(this, R.layout.amap_api_key_input_dialog, null); + + final TextInputEditText keyInput = view.findViewById(R.id.amapApiKey); + final TextView details = view.findViewById(R.id.amap_registration_details); + final MaterialButton copyButton = view.findViewById(R.id.amap_copy_registration_details); + + keyInput.setText(Optional.ofNullable(this.currentSettings.getAmapApiKey()).orElse("")); + + // Read from the installed package, so these are right for whichever build is in the + // user's hand rather than a documented value that goes stale when signing changes. + final String registrationDetails = SigningInfoUtil.getRegistrationDetails(this); + details.setText(registrationDetails); + + copyButton.setOnClickListener(v -> { + var clipboard = (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + clipboard.setPrimaryClip( + android.content.ClipData.newPlainText("AMap registration details", registrationDetails)); + Toast.makeText(this, R.string.amap_registration_details_copied, Toast.LENGTH_SHORT).show(); + }); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.amap_api_key) + .setView(view) + .setPositiveButton(R.string.accept, (dialog, which) -> { + final String entered = Optional.ofNullable(keyInput.getText()) + .map(CharSequence::toString) + .map(String::trim) + .orElse(""); + + this.currentSettings.setAmapApiKey(entered.isEmpty() ? null : entered); + this.saveSettings(); + + if (entered.isEmpty()) { + // Without a key AMap cannot render anything, so fall back rather + // than leaving a provider selected that will show a blank map. + Toast.makeText(this, R.string.amap_key_cleared, Toast.LENGTH_SHORT).show(); + if ("amap".equals(this.currentSettings.getMapProvider())) { + this.updateMapProvider("google"); + } + return; + } + + Toast.makeText(this, R.string.amap_key_saved, Toast.LENGTH_SHORT).show(); + if (providerToApplyOnSuccess != null) { + this.updateMapProvider(providerToApplyOnSuccess); + } + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + private void updateMapProvider(String provider) { final String currentProvider = this.currentSettings.getMapProvider(); this.mapProviderChanged = this.mapProviderChanged || !java.util.Objects.equals(currentProvider, provider); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index 42dc5e6a..9219732b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -22,6 +22,7 @@ public final class UserSettingsDataStore { public static final Preferences.Key ENABLE_DEBUG_DATA = PreferencesKeys.booleanKey("enable_debug_data"); public static final Preferences.Key MAP_PROVIDER = PreferencesKeys.stringKey("map_provider"); + public static final Preferences.Key AMAP_API_KEY = PreferencesKeys.stringKey("amap_api_key"); public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index fea2ca56..9b315157 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -1,5 +1,6 @@ package dev.wander.android.opentagviewer.db.repo; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.AMAP_API_KEY; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ANISETTE_SERVER_URL; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ENABLE_DEBUG_DATA; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; @@ -30,6 +31,7 @@ public UserSettings getUserSettings() { Boolean useDarkTheme = settings.get(USE_DARK_THEME); Boolean enableDebugData = settings.get(ENABLE_DEBUG_DATA); String mapProvider = settings.get(MAP_PROVIDER); + String amapApiKey = settings.get(AMAP_API_KEY); return UserSettings.builder() .anisetteServerUrl(anisetteServerUrl) @@ -37,6 +39,7 @@ public UserSettings getUserSettings() { .useDarkTheme(useDarkTheme) .enableDebugData(enableDebugData) .mapProvider(mapProvider) + .amapApiKey(amapApiKey) .build(); }).subscribeOn(Schedulers.io()) @@ -53,6 +56,9 @@ public Completable storeUserSettings(UserSettings userSettings) { mutablePreferences.set(USE_DARK_THEME, userSettings.getUseDarkTheme()); mutablePreferences.set(ENABLE_DEBUG_DATA, userSettings.getEnableDebugData()); mutablePreferences.set(MAP_PROVIDER, userSettings.getMapProvider()); + // Null would throw; an empty string reads back as "no key supplied". + mutablePreferences.set(AMAP_API_KEY, + userSettings.getAmapApiKey() == null ? "" : userSettings.getAmapApiKey()); return Single.just(mutablePreferences); }).subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index ef9ad2b7..028670cc 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -12,14 +12,28 @@ public class UserSettings { private Boolean enableDebugData; private String mapProvider; // "google" or "amap" + /** + * The user's own AMap (高德地图) API key. + *
+ * Not shipped with the app: AMap keys are issued per developer account, bound to a + * package name and signing fingerprint, and their terms expect the key holder to be + * the app's operator. So anyone wanting AMap supplies their own, the same way the + * Anisette server URL works. + */ + private String amapApiKey; + public boolean hasDarkThemeEnabled() { return this.useDarkTheme == Boolean.TRUE; } - + /** - * 获取地图提供商,默认为"google" + * The selected map provider, defaulting to Google Maps. */ public String getMapProvider() { return mapProvider != null && !mapProvider.isEmpty() ? mapProvider : "google"; } + + public boolean hasAmapApiKey() { + return this.amapApiKey != null && !this.amapApiKey.isBlank(); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java new file mode 100644 index 00000000..49eeda6d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.util.android; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; +import android.os.Build; +import android.util.Log; + +import java.security.MessageDigest; +import java.util.Locale; +import java.util.Optional; + +/** + * Reports the running app's package name and signing certificate fingerprints. + *
+ * AMap issues Android API keys bound to a package name plus a SHA-1 signing fingerprint, + * so anyone registering a key has to supply both. Reading them from the installed package + * at runtime means the values are always right for the build in the user's hand - debug or + * release, official or self-built - rather than a static value in a README that goes stale + * the moment a signing key changes. + */ +public final class SigningInfoUtil { + private static final String TAG = SigningInfoUtil.class.getSimpleName(); + + private SigningInfoUtil() {} + + /** + * @return the SHA-1 of the certificate this app was signed with, colon-separated and + * upper-case (the format AMap's console expects), or empty if it cannot be read. + */ + public static Optional getSha1Fingerprint(final Context context) { + return getFingerprint(context, "SHA-1"); + } + + public static Optional getSha256Fingerprint(final Context context) { + return getFingerprint(context, "SHA-256"); + } + + private static Optional getFingerprint(final Context context, final String algorithm) { + try { + final Signature[] signatures = getSignatures(context); + if (signatures == null || signatures.length == 0) { + Log.w(TAG, "No signatures found for this package"); + return Optional.empty(); + } + + // The first signer is the one certificate authorities and SDK consoles key off. + MessageDigest digest = MessageDigest.getInstance(algorithm); + byte[] hash = digest.digest(signatures[0].toByteArray()); + + StringBuilder out = new StringBuilder(hash.length * 3); + for (int i = 0; i < hash.length; i++) { + if (i > 0) { + out.append(':'); + } + out.append(String.format(Locale.ROOT, "%02X", hash[i])); + } + return Optional.of(out.toString()); + + } catch (Exception e) { + Log.e(TAG, "Failed to compute " + algorithm + " signing fingerprint", e); + return Optional.empty(); + } + } + + @SuppressWarnings("deprecation") // GET_SIGNATURES is the only option below API 28 + private static Signature[] getSignatures(final Context context) throws PackageManager.NameNotFoundException { + final PackageManager packageManager = context.getPackageManager(); + final String packageName = context.getPackageName(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageInfo info = packageManager.getPackageInfo( + packageName, PackageManager.GET_SIGNING_CERTIFICATES); + if (info.signingInfo == null) { + return null; + } + return info.signingInfo.hasMultipleSigners() + ? info.signingInfo.getApkContentsSigners() + : info.signingInfo.getSigningCertificateHistory(); + } + + PackageInfo info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); + return info.signatures; + } + + /** + * The block a user pastes into a map provider's console when registering a key. + */ + public static String getRegistrationDetails(final Context context) { + return "Package Name: " + context.getPackageName() + "\n" + + "SHA1: " + getSha1Fingerprint(context).orElse("(unavailable)"); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/amap_api_key_input_dialog.xml b/app/src/main/res/layout/amap_api_key_input_dialog.xml new file mode 100644 index 00000000..d14af726 --- /dev/null +++ b/app/src/main/res/layout/amap_api_key_input_dialog.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4434394c..fc38875a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -92,4 +92,10 @@ Codeanforderung fehlgeschlagen. Bitte versuchen Sie es erneut. Koreanisch (한국어) Japanisch (日本語) + AMap verlangt, dass jede Person einen eigenen API-Schlüssel angibt. Registriere einen unter lbs.amap.com, wähle als Plattform „Android SDK“ und füge ihn hier ein. + Paketname und SHA1 kopieren + Kopiert. Füge diese Angaben beim Erstellen des Schlüssels in der AMap-Konsole ein. + AMap benötigt einen eigenen API-Schlüssel. Gib einen ein, um diesen Anbieter zu verwenden. + AMap-API-Schlüssel gespeichert. + AMap-API-Schlüssel entfernt. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 649fdf50..1a6cace8 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -92,4 +92,10 @@ Failed to request code. Please try again. Korean (한국어) Japanese (日本語) + AMap requires each user to supply their own API key. Register one at lbs.amap.com, choosing "Android SDK" as the platform, and paste it here. + Copy package name and SHA1 + Copied. Paste these into the AMap console when creating your key. + AMap needs your own API key. Enter one to use this provider. + AMap API key saved. + AMap API key removed. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9a377cf0..ab26d950 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -92,4 +92,10 @@ Échec de la demande de code. Veuillez réessayer. Coréen (한국어) Japonais (日本語) + AMap exige que chaque personne fournisse sa propre clé d’API. Créez-en une sur lbs.amap.com en choisissant « Android SDK » comme plateforme, puis collez-la ici. + Copier le nom du package et le SHA1 + Copié. Collez ces informations dans la console AMap lors de la création de votre clé. + AMap nécessite votre propre clé d’API. Saisissez-en une pour utiliser ce fournisseur. + Clé d’API AMap enregistrée. + Clé d’API AMap supprimée. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 94156c71..4a7ead47 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -92,4 +92,10 @@ 二要素認証に失敗しました: %1$s \n別のAnisetteサーバーまたはApple アカウントを試してみませんか? エラーが発生しました。二要素認証コードの再送信をお試しください。 コードを要求できませんでした。もう一度お試しください。 + AMap を使うには、各ユーザーが自分の API キーを用意する必要があります。lbs.amap.com で「Android SDK」を選んで登録し、ここに貼り付けてください。 + パッケージ名と SHA1 をコピー + コピーしました。キーを作成する際に AMap コンソールへ貼り付けてください。 + AMap にはご自身の API キーが必要です。入力するとこの地図を利用できます。 + AMap の API キーを保存しました。 + AMap の API キーを削除しました。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5b45a759..02128d76 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -92,4 +92,10 @@ 오류가 발생했습니다. 2FA 코드 재승받기를 시도해주세요 코드 요청에 실패했습니다. 다시 시도해주세요. 한국어 + AMap을 사용하려면 사용자가 직접 API 키를 준비해야 합니다. lbs.amap.com에서 ‘Android SDK’를 선택해 등록한 뒤 여기에 붙여넣으세요. + 패키지 이름과 SHA1 복사 + 복사했습니다. 키를 만들 때 AMap 콘솔에 붙여넣으세요. + AMap에는 본인의 API 키가 필요합니다. 키를 입력해야 이 지도를 사용할 수 있습니다. + AMap API 키를 저장했습니다. + AMap API 키를 삭제했습니다. \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index cdb76bcd..d5ff57c2 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -92,4 +92,10 @@ Aanvragen van code mislukt. Probeer het opnieuw. Koreaans (한국어) Japans (日本語) + AMap vereist dat iedereen een eigen API-sleutel opgeeft. Registreer er een op lbs.amap.com, kies „Android SDK” als platform en plak hem hier. + Pakketnaam en SHA1 kopiëren + Gekopieerd. Plak deze gegevens in de AMap-console bij het aanmaken van je sleutel. + AMap heeft je eigen API-sleutel nodig. Voer er een in om deze aanbieder te gebruiken. + AMap API-sleutel opgeslagen. + AMap API-sleutel verwijderd. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 65f2ea3f..39193aa8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -92,4 +92,10 @@ Не удалось запросить код. Попробуйте еще раз. Корейский (한국어) Японский (日本語) + AMap требует, чтобы каждый пользователь указал собственный API-ключ. Зарегистрируйте его на lbs.amap.com, выбрав платформу «Android SDK», и вставьте сюда. + Скопировать имя пакета и SHA1 + Скопировано. Вставьте эти данные в консоль AMap при создании ключа. + Для AMap нужен ваш собственный API-ключ. Введите его, чтобы использовать этот источник карт. + API-ключ AMap сохранён. + API-ключ AMap удалён. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4101234e..edefe05c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -98,4 +98,10 @@ Google 地图 高德地图 (AMap) FindMy 库已更新,请重新登录。 + 高德地图需要您自行提供 API Key。请前往 lbs.amap.com 注册,平台选择“Android 平台 SDK”,然后将 Key 粘贴到此处。 + 复制包名和 SHA1 + 已复制。创建 Key 时请将这些信息粘贴到高德控制台。 + 高德地图需要您自己的 API Key。请先输入 Key 才能使用此地图源。 + 已保存高德地图 API Key。 + 已移除高德地图 API Key。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 76598014..8764f4ed 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -98,4 +98,10 @@ Google 地圖 高德地圖 (AMap) FindMy 函式庫已更新,請重新登入。 + 高德地圖需要您自行提供 API Key。請前往 lbs.amap.com 註冊,平台選擇「Android 平台 SDK」,然後將 Key 貼到此處。 + 複製套件名稱與 SHA1 + 已複製。建立 Key 時請將這些資訊貼到高德主控台。 + 高德地圖需要您自己的 API Key。請先輸入 Key 才能使用此地圖來源。 + 已儲存高德地圖 API Key。 + 已移除高德地圖 API Key。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec42cd70..5401b533 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,4 +131,11 @@ Google Maps 高德地图 (AMap) Please sign in again — the FindMy library has been updated. + AMap API Key + AMap requires each user to supply their own API key. Register one at lbs.amap.com, choosing "Android SDK" as the platform, and paste it here. + Copy package name and SHA1 + Copied. Paste these into the AMap console when creating your key. + AMap needs your own API key. Enter one to use this provider. + AMap API key saved. + AMap API key removed. diff --git a/local.defaults.properties b/local.defaults.properties index 3cd81a50..995b0da5 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1,2 +1 @@ MAPS_API_KEY=DEFAULT_API_KEY -AMAP_API_KEY=DEFAULT_AMAP_API_KEY \ No newline at end of file From 25af21e6cce93ca3e7a1f873bd8c2322f5f5b2cf Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 17:21:16 +0200 Subject: [PATCH 15/50] Link the AMap setup guide from the API key dialog Registering an AMap key means working through a Chinese-language console and knowing to supply the package name and signing fingerprint, which is not guessable from the dialog alone. Adds a button opening the wiki walkthrough, with the URL in app.properties alongside the existing project links rather than hardcoded. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/assets/app.properties | 4 +++- .../android/opentagviewer/SettingsActivity.java | 16 ++++++++++++++++ .../res/layout/amap_api_key_input_dialog.xml | 9 +++++++++ app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 13 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/assets/app.properties b/app/src/main/assets/app.properties index 2efe5345..e7a04af4 100644 --- a/app/src/main/assets/app.properties +++ b/app/src/main/assets/app.properties @@ -3,4 +3,6 @@ defaultAnisetteUrl=https://ani.sidestore.io developerWebsite=https://wander.dev projectGithub=https://github.com/parawanderer/OpenTagViewer -projectWiki=https://github.com/parawanderer/OpenTagViewer/wiki \ No newline at end of file +projectWiki=https://github.com/parawanderer/OpenTagViewer/wiki +# Linked from the AMap API key dialog, since users have to register their own key +amapWikiPage=https://github.com/parawanderer/OpenTagViewer/wiki/%5BMaps%5D-How-To:-Use-AMap-(%E9%AB%98%E5%BE%B7%E5%9C%B0%E5%9B%BE) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 59675fbd..99bab1cc 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -63,6 +63,7 @@ import dev.wander.android.opentagviewer.ui.extensions.AppAutoCompleteTextView; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.LocaleConfigUtil; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.SigningInfoUtil; import dev.wander.android.opentagviewer.util.validate.AnisetteUrlValidatorUtil; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -299,6 +300,21 @@ private void onClickEditAmapApiKey(final String providerToApplyOnSuccess) { Toast.makeText(this, R.string.amap_registration_details_copied, Toast.LENGTH_SHORT).show(); }); + // Registering a key is a multi-step process on a Chinese-language console, so send + // people to the walkthrough rather than expecting them to work it out. + final MaterialButton guideButton = view.findViewById(R.id.amap_open_guide); + guideButton.setOnClickListener(v -> { + var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); + if (properties == null) { + return; + } + final String guideUrl = properties.getProperty("amapWikiPage"); + if (guideUrl == null || guideUrl.isBlank()) { + return; + } + startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(guideUrl))); + }); + new MaterialAlertDialogBuilder(this) .setTitle(R.string.amap_api_key) .setView(view) diff --git a/app/src/main/res/layout/amap_api_key_input_dialog.xml b/app/src/main/res/layout/amap_api_key_input_dialog.xml index d14af726..2b73a083 100644 --- a/app/src/main/res/layout/amap_api_key_input_dialog.xml +++ b/app/src/main/res/layout/amap_api_key_input_dialog.xml @@ -48,6 +48,15 @@ android:layout_marginTop="4dp" android:text="@string/amap_copy_registration_details" /> + + AMap benötigt einen eigenen API-Schlüssel. Gib einen ein, um diesen Anbieter zu verwenden. AMap-API-Schlüssel gespeichert. AMap-API-Schlüssel entfernt. + Schlüssel anfordern \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1a6cace8..32349b7c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -98,4 +98,5 @@ AMap needs your own API key. Enter one to use this provider. AMap API key saved. AMap API key removed. + How to get a key \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ab26d950..4673c341 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -98,4 +98,5 @@ AMap nécessite votre propre clé d’API. Saisissez-en une pour utiliser ce fournisseur. Clé d’API AMap enregistrée. Clé d’API AMap supprimée. + Comment obtenir une clé \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4a7ead47..de8d8bff 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -98,4 +98,5 @@ AMap にはご自身の API キーが必要です。入力するとこの地図を利用できます。 AMap の API キーを保存しました。 AMap の API キーを削除しました。 + キーの取得方法 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 02128d76..74f33ca6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -98,4 +98,5 @@ AMap에는 본인의 API 키가 필요합니다. 키를 입력해야 이 지도를 사용할 수 있습니다. AMap API 키를 저장했습니다. AMap API 키를 삭제했습니다. + 키 발급 방법 \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d5ff57c2..63804725 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -98,4 +98,5 @@ AMap heeft je eigen API-sleutel nodig. Voer er een in om deze aanbieder te gebruiken. AMap API-sleutel opgeslagen. AMap API-sleutel verwijderd. + Een sleutel aanvragen \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 39193aa8..721b7f14 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -98,4 +98,5 @@ Для AMap нужен ваш собственный API-ключ. Введите его, чтобы использовать этот источник карт. API-ключ AMap сохранён. API-ключ AMap удалён. + Как получить ключ \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index edefe05c..2ff8fb7b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -104,4 +104,5 @@ 高德地图需要您自己的 API Key。请先输入 Key 才能使用此地图源。 已保存高德地图 API Key。 已移除高德地图 API Key。 + 如何获取 Key diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8764f4ed..45afe918 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -104,4 +104,5 @@ 高德地圖需要您自己的 API Key。請先輸入 Key 才能使用此地圖來源。 已儲存高德地圖 API Key。 已移除高德地圖 API Key。 + 如何取得 Key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5401b533..7a72fbef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,4 +138,5 @@ AMap needs your own API key. Enter one to use this provider. AMap API key saved. AMap API key removed. + How to get a key From 54506ed69fa4e1190f75535c38d5fd786e55a6c7 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 17:50:50 +0200 Subject: [PATCH 16/50] Gate AMap selection on the first-run screen too The API key gate only covered Settings. The first-run screen has its own map provider picker which saved the selection directly, so AMap could be chosen there with no key and nowhere to enter one - leaving a provider selected that can only render a blank map, on the very first screen a new user sees. Both screens now share one dialog. Selecting AMap without a key prompts for one and only applies the choice if a key is supplied; cancelling leaves the previous provider alone. Co-Authored-By: Claude Opus 5 (1M context) --- .idea/deploymentTargetSelector.xml | 9 -- .../opentagviewer/AppleLoginActivity.java | 19 ++++ .../opentagviewer/SettingsActivity.java | 74 +++----------- .../ui/settings/AmapApiKeyDialog.java | 96 +++++++++++++++++++ 4 files changed, 130 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b03b2c7e..39625e5c 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,15 +13,6 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index 88ffb266..79f8b78c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -56,6 +56,7 @@ import dev.wander.android.opentagviewer.service.web.GitHubService; import dev.wander.android.opentagviewer.service.web.GithubRawUtilityFilesService; import dev.wander.android.opentagviewer.ui.login.Apple2FACodeInputManager; +import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog; import dev.wander.android.opentagviewer.ui.settings.SharedMainSettingsManager; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; @@ -722,6 +723,24 @@ private void updateLocale(final String newLocale) { } private void updateMapProvider(final String newProvider) { + // AMap needs a key the user supplies themselves, so selecting it here without one + // would silently save a provider that can only render a blank map. Prompt instead, + // and only apply the choice once a key exists. + if ("amap".equals(newProvider) && !this.getUserSettings().hasAmapApiKey()) { + AmapApiKeyDialog.show(this, this.getUserSettings().getAmapApiKey(), enteredKey -> { + if (enteredKey == null) { + Toast.makeText(this, R.string.amap_key_required, Toast.LENGTH_LONG).show(); + this.sharedMainSettingsManager.setupMapProviderField(); + return; + } + this.getUserSettings().setAmapApiKey(enteredKey); + this.getUserSettings().setMapProvider(newProvider); + this.saveSettings(); + Log.i(TAG, "Updating app settings map provider to " + newProvider); + }); + return; + } + this.getUserSettings().setMapProvider(newProvider); this.saveSettings(); Log.i(TAG, "Updating app settings map provider to " + newProvider); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 99bab1cc..09e0651c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -60,6 +60,7 @@ import dev.wander.android.opentagviewer.service.web.GithubRawUtilityFilesService; import dev.wander.android.opentagviewer.service.web.sidestore.AnisetteServerSuggestion; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog; import dev.wander.android.opentagviewer.ui.extensions.AppAutoCompleteTextView; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.LocaleConfigUtil; @@ -280,70 +281,25 @@ private void onClickEditMapProvider() { * silently dropping it. */ private void onClickEditAmapApiKey(final String providerToApplyOnSuccess) { - View view = inflate(this, R.layout.amap_api_key_input_dialog, null); - - final TextInputEditText keyInput = view.findViewById(R.id.amapApiKey); - final TextView details = view.findViewById(R.id.amap_registration_details); - final MaterialButton copyButton = view.findViewById(R.id.amap_copy_registration_details); - - keyInput.setText(Optional.ofNullable(this.currentSettings.getAmapApiKey()).orElse("")); - - // Read from the installed package, so these are right for whichever build is in the - // user's hand rather than a documented value that goes stale when signing changes. - final String registrationDetails = SigningInfoUtil.getRegistrationDetails(this); - details.setText(registrationDetails); - - copyButton.setOnClickListener(v -> { - var clipboard = (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); - clipboard.setPrimaryClip( - android.content.ClipData.newPlainText("AMap registration details", registrationDetails)); - Toast.makeText(this, R.string.amap_registration_details_copied, Toast.LENGTH_SHORT).show(); - }); + AmapApiKeyDialog.show(this, this.currentSettings.getAmapApiKey(), enteredKey -> { + this.currentSettings.setAmapApiKey(enteredKey); + this.saveSettings(); - // Registering a key is a multi-step process on a Chinese-language console, so send - // people to the walkthrough rather than expecting them to work it out. - final MaterialButton guideButton = view.findViewById(R.id.amap_open_guide); - guideButton.setOnClickListener(v -> { - var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); - if (properties == null) { + if (enteredKey == null) { + // Without a key AMap cannot render anything, so fall back rather than + // leaving a provider selected that will only ever show a blank map. + Toast.makeText(this, R.string.amap_key_cleared, Toast.LENGTH_SHORT).show(); + if ("amap".equals(this.currentSettings.getMapProvider())) { + this.updateMapProvider("google"); + } return; } - final String guideUrl = properties.getProperty("amapWikiPage"); - if (guideUrl == null || guideUrl.isBlank()) { - return; + + Toast.makeText(this, R.string.amap_key_saved, Toast.LENGTH_SHORT).show(); + if (providerToApplyOnSuccess != null) { + this.updateMapProvider(providerToApplyOnSuccess); } - startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(guideUrl))); }); - - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.amap_api_key) - .setView(view) - .setPositiveButton(R.string.accept, (dialog, which) -> { - final String entered = Optional.ofNullable(keyInput.getText()) - .map(CharSequence::toString) - .map(String::trim) - .orElse(""); - - this.currentSettings.setAmapApiKey(entered.isEmpty() ? null : entered); - this.saveSettings(); - - if (entered.isEmpty()) { - // Without a key AMap cannot render anything, so fall back rather - // than leaving a provider selected that will show a blank map. - Toast.makeText(this, R.string.amap_key_cleared, Toast.LENGTH_SHORT).show(); - if ("amap".equals(this.currentSettings.getMapProvider())) { - this.updateMapProvider("google"); - } - return; - } - - Toast.makeText(this, R.string.amap_key_saved, Toast.LENGTH_SHORT).show(); - if (providerToApplyOnSuccess != null) { - this.updateMapProvider(providerToApplyOnSuccess); - } - }) - .setNegativeButton(R.string.cancel, null) - .show(); } private void updateMapProvider(String provider) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java new file mode 100644 index 00000000..93682cd4 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java @@ -0,0 +1,96 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static android.view.View.inflate; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; + +import java.util.Optional; +import java.util.function.Consumer; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.util.android.SigningInfoUtil; + +/** + * Prompt for the user's own AMap API key. + *
+ * Shared because AMap can be selected from two places - the first-run screen and Settings - + * and both need the same gate. No key ships with the app: AMap issues them per developer + * account, bound to a package name and signing fingerprint, and expects the key holder to + * be the app's operator. + */ +public final class AmapApiKeyDialog { + + private AmapApiKeyDialog() {} + + /** + * @param currentKey the key already stored, or null + * @param onResult receives the entered key, or null if the user cleared it. Not called + * if the dialog is cancelled, so a cancelled dialog leaves the current + * provider alone. + */ + public static void show( + final Activity activity, + final String currentKey, + final Consumer onResult) { + + View view = inflate(activity, R.layout.amap_api_key_input_dialog, null); + + final TextInputEditText keyInput = view.findViewById(R.id.amapApiKey); + final TextView details = view.findViewById(R.id.amap_registration_details); + final MaterialButton copyButton = view.findViewById(R.id.amap_copy_registration_details); + final MaterialButton guideButton = view.findViewById(R.id.amap_open_guide); + + keyInput.setText(Optional.ofNullable(currentKey).orElse("")); + + // Read from the installed package, so these stay correct for whichever build is in + // the user's hand rather than a documented value that goes stale when signing changes. + final String registrationDetails = SigningInfoUtil.getRegistrationDetails(activity); + details.setText(registrationDetails); + + copyButton.setOnClickListener(v -> { + var clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText("AMap registration details", registrationDetails)); + Toast.makeText(activity, R.string.amap_registration_details_copied, Toast.LENGTH_SHORT).show(); + }); + + // Registering a key means working through a Chinese-language console and knowing to + // supply the package name and fingerprint, so link the walkthrough. + guideButton.setOnClickListener(v -> { + var properties = PropertiesUtil.getProperties(activity.getAssets(), "app.properties"); + if (properties == null) { + return; + } + final String guideUrl = properties.getProperty("amapWikiPage"); + if (guideUrl == null || guideUrl.isBlank()) { + return; + } + activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(guideUrl))); + }); + + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.amap_api_key) + .setView(view) + .setPositiveButton(R.string.accept, (dialog, which) -> { + final String entered = Optional.ofNullable(keyInput.getText()) + .map(CharSequence::toString) + .map(String::trim) + .orElse(""); + onResult.accept(entered.isEmpty() ? null : entered); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } +} From 5a43e1df4b8a1191d4f867297ccbdc622eeac597 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 18:52:03 +0200 Subject: [PATCH 17/50] Export KeyAlignmentRecords from the macOS wizard macOS stores lastIndexObserved / lastIndexObservationDate per accessory under ~/Library/com.apple.icloud.searchpartyd/KeyAlignmentRecords//.record, encrypted with the same BeaconStore key as everything else. FindMy.py >= 0.9 accepts it as FindMyAccessory.from_plist(plist, key_alignment_plist). Without it an imported accessory starts at alignment_index 0 from its pairing date, so the first fetch searches the tag's entire lifetime of rolling keys. Measured against a real export: 50,563 keys for an 18-month-old AirTag, which at Apple's ~290-keys-per-request limit is roughly 175 requests for a single tag, all at once, the first time the app runs. That is the account-flagging risk raised in issue #30, and it applies to every import rather than only to upgrades. Confirmed against a real macOS Sonoma install: the folder exists and uses /.record, the same nesting as BeaconNamingRecord, so the existing decryption path handles it with no new logic - it was excluded only by the whitelist. The project's own manual-export wiki page currently tells users to skip this folder, which was correct for 0.7.6 and is now the thing costing them. The record is optional: macOS does not always have one, and versions predating key alignment have none at all. Exports without it still work exactly as before. Bumps the export format version to 0.0.2. The Android importer side is not done yet, so this only changes what the zip contains. Co-Authored-By: Claude Opus 5 (1M context) --- python/main/airtag_decryptor.py | 12 +++++++- python/main/wizard.py | 50 ++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/python/main/airtag_decryptor.py b/python/main/airtag_decryptor.py index cbdfe0d8..945b3486 100644 --- a/python/main/airtag_decryptor.py +++ b/python/main/airtag_decryptor.py @@ -35,11 +35,21 @@ OWNED_BEACONS = "OwnedBeacons" MASTER_BEACONS = "MasterBeacons" BEACON_NAMING_RECORD = "BeaconNamingRecord" +KEY_ALIGNMENT_RECORDS = "KeyAlignmentRecords" WHITELISTED_DIRS = { OWNED_BEACONS, MASTER_BEACONS, # <- MacOS 11 (see: https://github.com/parawanderer/OpenTagViewer/issues/24) - BEACON_NAMING_RECORD + BEACON_NAMING_RECORD, + # Holds `lastIndexObserved` / `lastIndexObservationDate` per accessory: where the tag's + # rolling key index had got to when macOS last saw it. FindMy.py >= 0.9 accepts this as + # `FindMyAccessory.from_plist(plist, key_alignment_plist)`. + # + # Without it, an imported accessory starts at index 0 from its pairing date, so the very + # first fetch searches the tag's entire lifetime of keys - roughly 96 per day, i.e. ~50k + # for an 18-month-old AirTag, and Apple accepts only ~290 keys per request. That is a few + # hundred requests per tag on first use. See issue #30. + KEY_ALIGNMENT_RECORDS } RENAME_LEGACY_MAP = { diff --git a/python/main/wizard.py b/python/main/wizard.py index 6d2f860e..f1254b03 100644 --- a/python/main/wizard.py +++ b/python/main/wizard.py @@ -14,6 +14,7 @@ from main.airtag_decryptor import ( BEACON_NAMING_RECORD, + KEY_ALIGNMENT_RECORDS, KEYCHAIN_LABEL, INPUT_PATH, MASTER_BEACONS, @@ -37,7 +38,7 @@ GITHUB_EXPORT_AIRTAGS_WIKI_LINK = "https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Export-AirTags-From-Mac" EXPORT_METADATA_FILENAME = "OPENTAGVIEWER.yml" -EXPORT_METADATA_VERSION = "0.0.1" +EXPORT_METADATA_VERSION = "0.0.2" EXPORT_METADATA_VIA_NAME = f"OpenTagViewer.app:{VERSION}" @@ -53,11 +54,14 @@ def __init__( owned_beacon: PListFileInfo, beacon_naming_record: PListFileInfo = None, beacon_name: str = None, - beacon_emoji: str = None): + beacon_emoji: str = None, + key_alignment_record: PListFileInfo = None): self.owned_beacon = owned_beacon self.beacon_naming_record = beacon_naming_record self.beacon_name = beacon_name self.beacon_emoji = beacon_emoji + # Optional: macOS does not always have one, and older macOS versions predate it. + self.key_alignment_record = key_alignment_record class WizardApp(tk.Tk): @@ -132,9 +136,14 @@ def _retrieve_beacon_data(self) -> dict[str, BeaconData]: self.quit() raise Exception("User does not want to give password access to keystore!") - def _read_all_plists(self, beacon_store_key: bytearray) -> tuple[list[PListFileInfo], list[PListFileInfo]]: - owned_beacons: list[PListFileInfo] - beacon_naming_records: list[PListFileInfo] + def _read_all_plists( + self, + beacon_store_key: bytearray + ) -> tuple[list[PListFileInfo], list[PListFileInfo], list[PListFileInfo]]: + owned_beacons: list[PListFileInfo] = [] + beacon_naming_records: list[PListFileInfo] = [] + key_alignment_records: list[PListFileInfo] = [] + for path, folders, _ in os.walk(INPUT_PATH): for foldername in folders: @@ -152,9 +161,11 @@ def _read_all_plists(self, beacon_store_key: bytearray) -> tuple[list[PListFileI owned_beacons = plists elif foldername == BEACON_NAMING_RECORD: beacon_naming_records = plists + elif foldername == KEY_ALIGNMENT_RECORDS: + key_alignment_records = plists break - return (owned_beacons, beacon_naming_records) + return (owned_beacons, beacon_naming_records, key_alignment_records) def _create_beacon_data_map(self) -> dict[str, BeaconData]: # get key: prompts password entry @@ -168,7 +179,7 @@ def _create_beacon_data_map(self) -> dict[str, BeaconData]: raise Exception(f"Failure to authenticate for '{KEYCHAIN_LABEL}' access!") # get needed files - owned_beacons, beacon_naming_records = self._read_all_plists(beacon_store_key) + owned_beacons, beacon_naming_records, key_alignment_records = self._read_all_plists(beacon_store_key) # map them by beaconId m: dict[str, BeaconData] = {} @@ -199,6 +210,17 @@ def _create_beacon_data_map(self) -> dict[str, BeaconData]: m[beacon_id].beacon_name = beacon_naming_record.data.get("name", "") m[beacon_id].beacon_emoji = beacon_naming_record.data.get("emoji", None) + # join them with their KeyAlignmentRecords. + # Layout is KeyAlignmentRecords//.record, so unlike + # naming records - which carry an "associatedBeacon" field - the beacon id comes + # from the parent directory name. + for key_alignment_record in key_alignment_records: + beacon_id: str = os.path.basename(os.path.dirname(key_alignment_record.filepath)) + if beacon_id not in m: + # Alignment data for a beacon we are not exporting; nothing to do. + continue + m[beacon_id].key_alignment_record = key_alignment_record + # cleanup any bad ones: for beacon_id in list(m.keys()): if m[beacon_id].beacon_naming_record is None: @@ -283,6 +305,20 @@ def _create_zip(self, output_zip_path: str, whitelisted_beacon_ids: list[str]): print(f"Now dumping '{beacon.owned_beacon.filepath}' to {output_file2}...") dump_plist(beacon.owned_beacon.data, output_file2) + # Optional: macOS does not always have one, and it is absent entirely on + # versions that predate key alignment. Without it FindMy.py starts an + # accessory at index 0 from its pairing date, making the first fetch after + # import search the tag's whole lifetime of keys. + if beacon.key_alignment_record is not None: + output_file3: str = make_output_path( + tmpdirname, + beacon.key_alignment_record.filepath, + INPUT_PATH, + rename_legacy=True + ) + print(f"Now dumping '{beacon.key_alignment_record.filepath}' to {output_file3}...") + dump_plist(beacon.key_alignment_record.data, output_file3) + # We need to make an export metadata file in the root dir... export_metadata = { "version": EXPORT_METADATA_VERSION, From 2157735f4a5a95bd229be3b41876bb18b82c5629 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 19:17:25 +0200 Subject: [PATCH 18/50] Import KeyAlignmentRecords so the first fetch is not a full history search Completes the chain the wizard change started. The exported KeyAlignmentRecord is parsed from the zip, stored on the beacon, and handed to FindMyAccessory.from_plist(plist, key_alignment_plist), so an imported accessory starts at the key index macOS last observed rather than at index 0 from its pairing date. Without it the first fetch spans the tag's whole life: 50,563 keys for the 18-month-old tag in the test fixture, roughly 175 requests at Apple's ~290-keys -per-request limit, for a single tag. That is not an upgrade-only problem - it applies to every fresh import too, since the exporter never captured this record. - Room migration 2 to 3 adds alignment_plist, a pure additive ALTER - Existing rows stay NULL, which is correct: their exports predate format 0.0.2 and have no alignment record. Those beacons behave exactly as before and still rely on the probe in main.py - The raw record is retained rather than only converted, so a future FindMy.py reading more of it can re-derive without a re-export Covered by a migration test for 2 to 3, one for a direct 1 to 3 upgrade (users skip releases, so both migrations must run in sequence), backfill tests asserting the stored record reaches the converter and that its absence is still handled, and a Python test proving the alignment record actually collapses the key search rather than merely being accepted. Also corrects Python type annotations flagged by pyright: several functions returned None while annotated otherwise, two took `str = None` defaults, and the alignment record was passed to from_plist as a BytesIO, which only its first parameter accepts - it now passes bytes, which the signature documents. Adds AGENTS.md documenting the constraints that are easy to violate by accident: migrations, remote-only Anisette, no bundled AMap key, both key-alignment paths, and the Windows traps that have each already cost a debugging session. Co-Authored-By: Claude Opus 5 (1M context) --- .idea/androidTestResultsUserPreferences.xml | 13 + .idea/deploymentTargetSelector.xml | 7 +- AGENTS.md | 141 ++++++ .../3.json | 417 ++++++++++++++++++ .../db/repo/BeaconRepositoryBackfillTest.java | 38 +- .../OpenTagViewerDatabaseMigrationTest.java | 63 +++ .../db/repo/BeaconRepository.java | 3 +- .../db/room/OpenTagViewerDatabase.java | 19 +- .../db/room/entity/OwnedBeacon.java | 15 + ...ChaquopyPlistToAccessoryJsonConverter.java | 5 +- .../python/PlistToAccessoryJsonConverter.java | 8 +- .../util/parse/AppleZipImporterUtil.java | 39 +- app/src/main/python/main.py | 48 +- app/src/test/python/test_main.py | 74 ++++ 14 files changed, 859 insertions(+), 31 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 8c34214c..0b7444f5 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -16,6 +16,19 @@ + + + + + + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 39625e5c..fcd0d852 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,15 +4,18 @@ + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..23a05b00 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# AGENTS.md + +Rules for agents working on OpenTagViewer. Written for automated contributors, but the +constraints apply to anyone. + +## What this project is + +An Android app that shows Apple AirTag locations without an Apple device. Tags are exported +from a Mac once (see the wiki), imported as a zip, and located via Apple's Find My network +through [FindMy.py](https://github.com/malmeloo/FindMy.py). + +| Layer | Technology | +| --- | --- | +| App | Android, **Java** (not Kotlin), Gradle Kotlin DSL, `compileSdk`/`targetSdk` 35, `minSdk` 24 | +| Apple protocol | **Chaquopy** — CPython 3.12 embedded, bridged in `app/src/main/python/main.py` | +| Persistence | **Room** (`opentagviewer-db`) | +| Async | **RxJava3** through repositories and services | +| Boilerplate | **Lombok** (`@Builder`, `@Getter` on entities) | +| Desktop exporter | `python/` — tkinter wizard, PyInstaller | + +`app/src/main/python/` is packaged into the APK by Chaquopy. Nothing in it may import +Android or Java types — that is what makes it testable on plain CPython. + +--- + +## Rules + +### 1. Never ship a Room schema change without a migration + +Bumping `version` without a `Migration` throws at first database access **on upgrade only**. +A fresh install works fine, so it passes casual testing and then destroys every existing +user's imported beacons and location history. + +- Add a `Migration` and register it in `addMigrations(...)` +- **Never** use `fallbackToDestructiveMigration()` — recovery means redoing the macOS + export, and `allowBackup` is false so there is no backup +- Add a migration test, including the direct path from the oldest version (users skip + releases) +- Schemas are exported to `app/schemas/` and committed; include the new JSON in your change + +### 2. Do not claim something works if you have not run it + +Say plainly what was verified, what was assumed, and what could not be checked. If a change +needs a real Apple account, a real Mac, or hardware you do not have, state that rather than +implying coverage. A wrong "this works" is worse here than an honest gap, because the +failure mode is silent data loss on other people's devices. + +### 3. Verify library behaviour against the installed version + +FindMy.py's API changes significantly between minor versions. Methods and properties that +existed in 0.7.x are gone in 0.9.x. Before "fixing" code that looks wrong, check the pinned +version's actual source — several apparent bugs here are forced adaptations. + +```bash +python -m venv .venv && .venv/bin/pip install "FindMy==" +``` + +### 4. Respect the Anisette constraints + +- **Remote only.** `anisette` needs `unicorn`, a CPU emulator Chaquopy cannot build for + Android. `app/stubs/unicorn/` is a stand-in that makes the dependency tree resolve; every + method raises. Local Anisette does not work, and the stub is not a step toward it. +- **Sessions are bound to one server's machine identity.** Changing Anisette server requires + a re-login. That is inherent to how Apple binds the session, not a bug to work around — + rewriting the stored provider would leave the app running but silently failing auth. + +### 5. Never bundle an AMap API key + +AMap issues keys per developer account, bound to a package name and signing fingerprint, and +expects the key holder to be the app's operator. Users supply their own in Settings, applied +at runtime via `MapsInitializer.setApiKey`. + +Selecting AMap without a key must not save — both in Settings and on the first-run screen. + +### 6. Keep both key-alignment paths working + +Without a `KeyAlignmentRecord`, `FindMyAccessory` starts at index 0 from its pairing date, so +the first fetch searches the tag's entire history — ~50,000 keys for an 18-month-old tag, at +Apple's ~290-keys-per-request limit. That is an account-flagging risk, not just slowness. + +- Exports from format `0.0.2` onward carry the record; it is passed to + `FindMyAccessory.from_plist(plist, key_alignment_plist)` +- Older exports have none and fall back to the probe in `main.py` +- Changes must not break either path + +### 7. Add map providers behind `IMapProvider` + +Do not branch inside `MapsActivity`. The abstraction exists precisely so a new provider is a +new implementation — a third party added a MapLibre provider in ~80 lines because of it. + +### 8. Attribute other people's work + +When merging a contributor's changes, preserve authorship — cherry-pick, or set `--author`, +rather than copying file contents into your own commit. Credit them in the PR description. + +--- + +## Building and testing + +See **[TESTING.md](./TESTING.md)** for every suite. Short version: + +```bash +./gradlew testAll # everything that needs no device +./gradlew testAllOnDevice # the above plus instrumented tests +``` + +Python must be on `PATH` — the build shells out to it to generate the unicorn stub wheel. + +**Type-check Python with pyright, not mypy.** The editors used here run Pylance, which is +pyright; default mypy is far more lenient and will pass code your editor flags. + +```bash +python -m pyright app/src/main/python/main.py +``` + +## Windows traps + +Each of these has already produced a hang that looked like something else: + +- **`python3` is usually a trap.** It resolves to a zero-byte Microsoft Store alias in + `%LOCALAPPDATA%\Microsoft\WindowsApps\` which hangs forever when run non-interactively + rather than failing. Prefer `python`; reject candidates whose file length is zero. +- **Git Bash rewrites POSIX paths.** `adb push ... /sdcard/Download/` becomes + `C:/Program Files/Git/sdcard/...`. `MSYS_NO_PATHCONV=1` fixes that but *also* ~~stops~~ + `JAVA_HOME` being translated, so `gradlew.bat` gets a POSIX path and dies. Never set it + globally. +- **adb appears to hang from Git Bash** on `start-server`, because MSYS waits on the + daemon's inherited handles. Use PowerShell, or have Android Studio open — it runs its own + adb server. +- **Run instrumented tests from the Android Studio UI.** They take ~25 seconds. If a CLI run + seems to hang, suspect the environment — and never wrap the command in a `grep` that can + swallow the error message. + +## Conventions + +- Java, not Kotlin, in `app/`. Match the surrounding style. +- Entities use Lombok `@Builder`; construct them that way. +- Comments explain *why*, especially where behaviour looks wrong but is forced by Apple's or + FindMy.py's API. +- User-facing strings go in `values/strings.xml` with translations in every supported locale. +- Prefer fixing the root cause to adding a workaround. diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json new file mode 100644 index 00000000..cee25fc9 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json @@ -0,0 +1,417 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "66f08a6fd861873f88fb7e1ae84b4070", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alignmentPlist", + "columnName": "alignment_plist", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '66f08a6fd861873f88fb7e1ae84b4070')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java index 762f1690..fb54172e 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java @@ -47,6 +47,7 @@ public class BeaconRepositoryBackfillTest { /** Records what it was asked to convert so tests can assert on call count and input. */ private static class RecordingConverter implements PlistToAccessoryJsonConverter { final List calls = new ArrayList<>(); + final List alignmentCalls = new ArrayList<>(); private final String result; RecordingConverter(String result) { @@ -54,8 +55,9 @@ private static class RecordingConverter implements PlistToAccessoryJsonConverter } @Override - public String convert(String plistXml) { + public String convert(String plistXml, String alignmentPlistXml) { this.calls.add(plistXml); + this.alignmentCalls.add(alignmentPlistXml); return this.result; } } @@ -74,6 +76,10 @@ public void tearDown() { } private void insertBeacon(String beaconId, String content, String accessoryJson) { + insertBeacon(beaconId, content, accessoryJson, null); + } + + private void insertBeacon(String beaconId, String content, String accessoryJson, String alignmentPlist) { this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() .id(beaconId) .importId(null) @@ -81,9 +87,39 @@ private void insertBeacon(String beaconId, String content, String accessoryJson) .version("1.0") .isRemoved(false) .accessoryJson(accessoryJson) + .alignmentPlist(alignmentPlist) .build()); } + /** + * The alignment record is what stops the first fetch searching the tag's whole + * history, so the backfill has to hand it to the converter rather than dropping it. + */ + @Test + public void backfillPassesTheStoredAlignmentRecordToTheConverter() { + insertBeacon("beacon-a", PLIST, null, "ALIGNMENT-PLIST"); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, converter.alignmentCalls.size()); + assertEquals("ALIGNMENT-PLIST", converter.alignmentCalls.get(0)); + } + + /** Exports predating format 0.0.2 have no alignment record; that must still convert. */ + @Test + public void backfillWorksWithoutAnAlignmentRecord() { + insertBeacon("beacon-a", PLIST, null, null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + var requests = repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertNull("no alignment record should be passed as null", converter.alignmentCalls.get(0)); + } + /** The migrated case: NULL accessory_json is converted from the retained plist and persisted. */ @Test public void backfillsAndPersistsWhenAccessoryJsonIsNull() { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java index e5447c2a..d66d5e84 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java @@ -165,6 +165,69 @@ public void migrate1To2_preservesLocationHistory() throws IOException { } } + /** + * v2 → v3 adds alignment_plist. Existing rows keep their data and gain a NULL column, + * which is correct: their exports predate format 0.0.2 and have no alignment record. + */ + @Test + public void migrate2To3_preservesExistingBeacons() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + // reach v2 the way a real device would, then apply the new one + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + + try (Cursor cursor = db.query( + "SELECT content, accessory_json, alignment_plist FROM OwnedBeacons WHERE id = ?", + new Object[]{BEACON_ID})) { + assertTrue("beacon row did not survive v2 to v3", cursor.moveToFirst()); + assertEquals(BEACON_PLIST, cursor.getString(0)); + assertTrue("accessory_json should still be NULL", cursor.isNull(1)); + assertTrue("alignment_plist starts NULL for pre-0.0.2 exports", cursor.isNull(2)); + } + } + + /** + * The case that actually reaches users: someone who never took the v2 release and + * upgrades straight from v1. Both migrations have to run in sequence. + */ + @Test + public void migrate1To3_directUpgradePreservesEverything() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + insertLocationReport(db, "hash-1", "beacon-a", 1700000000000L); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 3, true, + OpenTagViewerDatabase.MIGRATION_1_2, + OpenTagViewerDatabase.MIGRATION_2_3); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons lost on a direct v1 to v3 upgrade", 2, cursor.getInt(0)); + } + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM LocationReport")) { + assertTrue(cursor.moveToFirst()); + assertEquals("location history lost on a direct v1 to v3 upgrade", 1, cursor.getInt(0)); + } + + try (Cursor cursor = db.query( + "SELECT accessory_json, alignment_plist FROM OwnedBeacons WHERE id = ?", + new Object[]{"beacon-a"})) { + assertTrue(cursor.moveToFirst()); + assertTrue(cursor.isNull(0)); + assertTrue(cursor.isNull(1)); + } + } + private static void insertImport(SupportSQLiteDatabase db, long id) { ContentValues values = new ContentValues(); values.put("id", id); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index 4dc8ab8a..a401e7e4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -173,7 +173,8 @@ public Observable> toAccessoryRequests(Map + * Supplies the rolling-key index macOS last observed, which + * {@code FindMyAccessory.from_plist(plist, key_alignment_plist)} uses as its starting + * point. Without it an accessory starts at index 0 from its pairing date, so the first + * fetch searches the tag's whole history - tens of thousands of keys for an older tag. + *
+ * Nullable: exports made before format 0.0.2 do not contain one, and macOS has none for + * accessories it has never observed. Retained rather than only converted, so a future + * FindMy.py that reads more of this record can re-derive from it. + */ + @ColumnInfo(name = "alignment_plist") + public String alignmentPlist; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java index d58a49fd..487ac57f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java @@ -16,14 +16,15 @@ public class ChaquopyPlistToAccessoryJsonConverter implements PlistToAccessoryJs private static final String MODULE_MAIN = "main"; @Override - public String convert(final String plistXml) { + public String convert(final String plistXml, final String alignmentPlistXml) { if (plistXml == null || plistXml.isEmpty()) { return null; } try { var module = Python.getInstance().getModule(MODULE_MAIN); - var converted = module.callAttr("convertPlistToJson", plistXml); + // Passing null for the alignment record is fine - Python treats it as absent. + var converted = module.callAttr("convertPlistToJson", plistXml, alignmentPlistXml); return converted == null ? null : converted.toString(); } catch (Exception e) { // Either Python has not started yet, or the plist is not one FindMy 0.9.x can diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java index a8c220b8..f1255da2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java @@ -11,11 +11,15 @@ public interface PlistToAccessoryJsonConverter { /** - * @param plistXml the raw plist XML as stored in {@code OwnedBeacons.content} + * @param plistXml the raw plist XML as stored in {@code OwnedBeacons.content} + * @param alignmentPlistXml the accessory's KeyAlignmentRecord plist, or {@code null}. + * Supplies the rolling-key index macOS last observed so fetching + * can start there instead of at the tag's pairing date. Absent + * for exports made before format 0.0.2. * @return the serialized accessory JSON, or {@code null} if conversion was not * possible. Callers should treat {@code null} as "not available right now" * and retry later, not as a permanent failure - a conversion can fail simply * because the Python runtime has not started yet. */ - String convert(String plistXml); + String convert(String plistXml, String alignmentPlistXml); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java index ca27b537..cb9cdbb5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java @@ -54,7 +54,13 @@ public class AppleZipImporterUtil { enum FILE_TYPE { EXPORT_INFO, OWNED_BEACON, - BEACON_NAMING_RECORD; + BEACON_NAMING_RECORD, + /** + * Optional. Carries the rolling-key alignment macOS last observed for an accessory, + * which lets FindMy 0.9.x start fetching from the right key index instead of + * searching the tag's entire history. Absent from exports made before format 0.0.2. + */ + KEY_ALIGNMENT_RECORD; } private static final String TAG = AppleZipImporterUtil.class.getSimpleName(); @@ -80,6 +86,9 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio String openTagViewerYaml = null; Map ownedBeacons = new HashMap<>(); Map> beaconNamingRecords = new HashMap<>(); + // Optional per accessory: absent for exports made before format 0.0.2, and for + // accessories macOS has never observed a key index for. + Map keyAlignmentRecords = new HashMap<>(); try (ZipInputStream zipInput = new ZipInputStream( this.appContext.getContentResolver() @@ -131,6 +140,10 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio case BEACON_NAMING_RECORD: processBeaconNamingRecord(typeAndRegexGroups.second, fileContent, beaconNamingRecords); break; + case KEY_ALIGNMENT_RECORD: + // Keyed by the parent directory, which is the accessory id. + keyAlignmentRecords.put(typeAndRegexGroups.second.get(1), fileContent); + break; } } } catch (IOException e) { @@ -144,7 +157,8 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio return convert( openTagViewerYaml, ownedBeacons, - beaconNamingRecords + beaconNamingRecords, + keyAlignmentRecords ); } @@ -382,10 +396,11 @@ private static Pair> getAllowedFileType(final String fil private static ImportData convert( final String importInfo, final Map ownedBeacons, - final Map> beaconNamingRecords) { + final Map> beaconNamingRecords, + final Map keyAlignmentRecords) { try { Import anImport = parseImportInfo(importInfo); - List beacons = makeOwnedBeacons(ownedBeacons, anImport.version); + List beacons = makeOwnedBeacons(ownedBeacons, keyAlignmentRecords, anImport.version); List records = makeBeaconNamingRecords(beaconNamingRecords, anImport.version); return new ImportData( @@ -410,25 +425,33 @@ private static Import parseImportInfo(final String importInfo) throws JsonProces .build(); } - private static List makeOwnedBeacons(final Map ownedBeacons, final String version) { + private static List makeOwnedBeacons( + final Map ownedBeacons, + final Map keyAlignmentRecords, + final String version) { return ownedBeacons.entrySet().stream() .map(kvp -> OwnedBeacon.builder() .id(kvp.getKey()) .importId(null) // TODO: fill on create Import .version(version) .content(kvp.getValue()) + // Null when the export predates format 0.0.2 or macOS had no + // alignment for this accessory; conversion handles that. + .alignmentPlist(keyAlignmentRecords.get(kvp.getKey())) // Eagerly convert plist → JSON for FindMy 0.9.x; nullable on failure // (lazy backfill in BeaconRepository will retry on first fetch). - .accessoryJson(plistToAccessoryJsonOrNull(kvp.getValue())) + .accessoryJson(plistToAccessoryJsonOrNull( + kvp.getValue(), keyAlignmentRecords.get(kvp.getKey()))) .build()) .collect(Collectors.toList()); } - private static String plistToAccessoryJsonOrNull(final String plistXml) { + private static String plistToAccessoryJsonOrNull( + final String plistXml, final String alignmentPlistXml) { try { var py = com.chaquo.python.Python.getInstance(); var module = py.getModule("main"); - var result = module.callAttr("convertPlistToJson", plistXml); + var result = module.callAttr("convertPlistToJson", plistXml, alignmentPlistXml); return result == null ? null : result.toString(); } catch (Exception e) { Log.w(TAG, "convertPlistToJson at import time failed; will backfill lazily on first fetch", e); diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 78e889a7..8fcbfabf 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Any import json import traceback from datetime import datetime, timezone @@ -25,7 +26,7 @@ class TwoFactorMethods(Enum): PHONE = 2 -def _toUnixEpochMs(dt: datetime) -> int: +def _toUnixEpochMs(dt: datetime | None) -> int | None: """ Convert datetime to unix epoch (milliseconds) """ @@ -59,7 +60,7 @@ def foo(arg: str): } -def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: +def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict | None: """ Extract some extra information from within the plist file `cloudKitMetadata` node (that is followed by a `` element containing base64) @@ -90,9 +91,9 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: # This is actually a pretty large object, but very little of the data seems useful to our app - RecordCtime: datetime = d_dict.get("RecordCtime", None) - RecordMtime: datetime = d_dict.get("RecordMtime", None) - ModifiedByDevice: str = d_dict.get("ModifiedByDevice", None) + RecordCtime: datetime | None = d_dict.get("RecordCtime", None) + RecordMtime: datetime | None = d_dict.get("RecordMtime", None) + ModifiedByDevice: str | None = d_dict.get("ModifiedByDevice", None) res = { "creationTime": _toUnixEpochMs(RecordCtime), @@ -109,8 +110,11 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: return None -def _convertToJavaDictWrapper(method: SyncSecondFactorMethod): - return_obj = { +def _convertToJavaDictWrapper(method: SyncSecondFactorMethod) -> dict[str, Any]: + # Deliberately heterogeneous: it carries the method object plus the ints and strings + # the Java side reads back out. Without the annotation the type is inferred from the + # first entry alone, and every later assignment looks like an error. + return_obj: dict[str, Any] = { "obj": method } @@ -189,7 +193,7 @@ def exportToString(account: AppleAccount) -> str: SUPPORTED_ANISETTE_TYPE = "aniRemote" -def assertAnisetteIsSupported(serializedAccountData: str) -> str: +def assertAnisetteIsSupported(serializedAccountData: str) -> str | None: """ Check a stored account uses a provider we can actually run, before anything tries to use it. @@ -219,7 +223,9 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str: return "This saved login could not be read." -def getAccount(serializedAccountData: str, anisetteServerUrl: str = None) -> AppleAccount: +def getAccount( + serializedAccountData: str, + anisetteServerUrl: str | None = None) -> AppleAccount | None: """ Restore an AppleAccount via FindMy 0.9.x's `from_json`. The anisette provider is rebuilt from the embedded state inside the JSON, so `anisetteServerUrl` is unused here. We accept @@ -245,7 +251,9 @@ def getAccount(serializedAccountData: str, anisetteServerUrl: str = None) -> App return None -def convertPlistToJson(plistXmlString: str) -> str: +def convertPlistToJson( + plistXmlString: str, + alignmentPlistXmlString: str | None = None) -> str | None: """ One-shot conversion from the legacy plist XML representation (still stored in OwnedBeacon.content) to the JSON form that FindMy 0.9.x expects. @@ -254,11 +262,25 @@ def convertPlistToJson(plistXmlString: str) -> str: - During .zip import: convert once and store alongside the raw plist - As a lazy backfill: when reading an OwnedBeacon row that predates the upgrade + `alignmentPlistXmlString` is the accessory's KeyAlignmentRecord, if the export + contained one (format 0.0.2 and later). It supplies the rolling-key index macOS last + observed, so fetching can start there. Without it the accessory starts at index 0 from + its pairing date and the first fetch searches the tag's entire history - tens of + thousands of keys for an older tag. Optional, because exports predating 0.0.2 have no + such record and must keep working. + Returns None on failure so Java can decide how to recover. """ try: fp = BytesIO(plistXmlString.encode('utf-8')) - accessory = FindMyAccessory.from_plist(fp) + + # from_plist accepts bytes for the alignment record but not a file object, + # unlike its first parameter. + alignment_bytes = ( + alignmentPlistXmlString.encode('utf-8') if alignmentPlistXmlString else None + ) + + accessory = FindMyAccessory.from_plist(fp, alignment_bytes) return json.dumps(accessory.to_json()) except Exception: print(f"convertPlistToJson failed: {traceback.format_exc()}") @@ -363,7 +385,7 @@ def _serializeReports(reports): def getLastReports( account: AppleAccount, idToAccessoryData, - hoursBack: int) -> dict: + hoursBack: int) -> dict | None: """ Fetch the most recent reports for each beacon over the requested time window. @@ -425,7 +447,7 @@ def getReports( account: AppleAccount, idToAccessoryData, unixStartMs: int, - unixEndMs: int) -> dict: + unixEndMs: int) -> dict | None: """ Time-range variant. Apple's network only retains ~7 days of history; ranges further back than that will return empty (the local Room cache is already the canonical store diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index cc8e16e0..febeb883 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -308,3 +308,77 @@ def get_max_index(self, _dt): assert main._narrowAlignmentIfNeeded(account, BrokenAccessory(), now - timedelta(hours=24), now) is False assert account.fetch_location_calls == 0 + +# -------------------------------------------------------------------------- +# Key alignment records +# +# The whole point: without one, an accessory starts at index 0 from its pairing +# date and the first fetch searches the tag's entire history. With one, it starts +# where macOS last observed the key index. +# -------------------------------------------------------------------------- + +def _alignment_plist_bytes(days_ago: int, index: int) -> bytes: + """A KeyAlignmentRecord in the shape macOS writes it.""" + import plistlib + observed = (datetime.now(tz=timezone.utc) - timedelta(days=days_ago)).replace( + tzinfo=None, microsecond=0) + return plistlib.dumps({ + "lastIndexObservationDate": observed, + "lastIndexObserved": index, + }) + + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +def test_alignment_record_collapses_the_first_fetch_key_search(): + """An old tag with an alignment record must not search its whole history.""" + from findmy import FindMyAccessory + + plist_path = BEACON_PLISTS[0] + now = datetime.now(tz=timezone.utc) + start = now - timedelta(hours=24) + + without = FindMyAccessory.from_plist(plist_path) + unaligned_keys = without.get_max_index(now) - without.get_min_index(start) + 1 + + # Same accessory, but macOS observed its index yesterday. + import io as _io + with_alignment = FindMyAccessory.from_plist( + plist_path, _io.BytesIO(_alignment_plist_bytes(days_ago=1, index=50_000))) + aligned_keys = with_alignment.get_max_index(now) - with_alignment.get_min_index(start) + 1 + + assert aligned_keys < unaligned_keys, ( + f"alignment record did not narrow the search " + f"({aligned_keys} vs {unaligned_keys} keys)" + ) + # Apple accepts ~290 keys per request; a day's drift either side should stay small. + assert aligned_keys < 1000, f"expected a bounded search, got {aligned_keys} keys" + + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +def test_convertPlistToJson_accepts_an_alignment_record(): + plist_xml = _read_plist_xml(BEACON_PLISTS[0]) + alignment_xml = _alignment_plist_bytes(days_ago=1, index=50_000).decode("utf-8") + + result = main.convertPlistToJson(plist_xml, alignment_xml) + + assert result is not None + parsed = json.loads(result) + assert parsed.get("type") == "accessory" + + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +def test_convertPlistToJson_still_works_without_an_alignment_record(): + """Exports predating format 0.0.2 have none, and must keep importing.""" + plist_xml = _read_plist_xml(BEACON_PLISTS[0]) + assert main.convertPlistToJson(plist_xml) is not None + assert main.convertPlistToJson(plist_xml, None) is not None + + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +def test_convertPlistToJson_survives_a_corrupt_alignment_record(): + """A bad alignment record must not cost us the beacon entirely.""" + plist_xml = _read_plist_xml(BEACON_PLISTS[0]) + result = main.convertPlistToJson(plist_xml, "this is not a plist") + # from_plist raises on a malformed alignment plist, so we return None and the caller + # retries later rather than storing something wrong. + assert result is None From c829a68b38535e1f0af085b0621b087762cd87e6 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 19:47:16 +0200 Subject: [PATCH 19/50] Fix map buttons jumping into the status bar when there are no tags buttons_bottom_right was anchored only with layout_above="@id/tags_scrollable_area". RelativeLayout ignores layout_above when its anchor is GONE, and MapsActivity hides tags_scrollable_area whenever there is nothing to show. With no other vertical rule the buttons fell back to the default position - the top of the parent - and rendered underneath the status bar. That is every user's first launch, before they have imported anything. It would not show up for anyone testing with tags already present, which is presumably why it survived. Restores layout_alignParentBottom alongside layout_above so there is a fallback rule. The previous code had it, but it was dropped when the anchoring changed to follow the tag area. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/res/layout/activity_maps.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/layout/activity_maps.xml b/app/src/main/res/layout/activity_maps.xml index 699fa36d..a4c493fe 100644 --- a/app/src/main/res/layout/activity_maps.xml +++ b/app/src/main/res/layout/activity_maps.xml @@ -68,11 +68,17 @@ + Date: Sat, 8 Aug 2026 20:04:43 +0200 Subject: [PATCH 20/50] Show Chinese language options in the same style as every other language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language picker lists entries as " ()" - "Dutch (Nederlands)", "Japanese (日本語)". The two Chinese options were added only to values/, values-zh-rCN and values-zh-rTW, so in every other locale they fell back to the default and rendered as bare native names, inconsistent with the rest of the list and unreadable to anyone who cannot already read them. Adds lang_zh_cn and lang_zh_tw to the seven remaining locales, following each one's existing convention. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-ja/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-nl/strings.xml | 2 ++ app/src/main/res/values-ru/strings.xml | 2 ++ 7 files changed, 14 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e8afe42c..ce9c496e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -99,4 +99,6 @@ AMap-API-Schlüssel gespeichert. AMap-API-Schlüssel entfernt. Schlüssel anfordern + Chinesisch, vereinfacht (简体中文) + Chinesisch, traditionell (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 32349b7c..5b4d9e5d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -99,4 +99,6 @@ AMap API key saved. AMap API key removed. How to get a key + Chinese, Simplified (简体中文) + Chinese, Traditional (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4673c341..0d9ec88e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -99,4 +99,6 @@ Clé d’API AMap enregistrée. Clé d’API AMap supprimée. Comment obtenir une clé + Chinois simplifié (简体中文) + Chinois traditionnel (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index de8d8bff..65f63f37 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -99,4 +99,6 @@ AMap の API キーを保存しました。 AMap の API キーを削除しました。 キーの取得方法 + 中国語 簡体字 (简体中文) + 中国語 繁体字 (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 74f33ca6..b3251897 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -99,4 +99,6 @@ AMap API 키를 저장했습니다. AMap API 키를 삭제했습니다. 키 발급 방법 + 중국어 간체 (简体中文) + 중국어 번체 (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 63804725..3778e874 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -99,4 +99,6 @@ AMap API-sleutel opgeslagen. AMap API-sleutel verwijderd. Een sleutel aanvragen + Chinees, vereenvoudigd (简体中文) + Chinees, traditioneel (繁體中文) \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 721b7f14..be65a9d6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -99,4 +99,6 @@ API-ключ AMap сохранён. API-ключ AMap удалён. Как получить ключ + Китайский упрощённый (简体中文) + Китайский традиционный (繁體中文) \ No newline at end of file From 055a17cc29355b9e0ad89e358d437274d615f4e8 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 20:26:45 +0200 Subject: [PATCH 21/50] Stop a wide key search costing double, and serialise calls into Python Three problems found by running this against real tags. The alignment probe ran *before* the history fetch. For an accessory with recent reports that was a big win - measured 50,583 indices down to 95 on a real tag. But for one with no recent reports the probe traversed the entire range, found nothing, narrowed nothing, and then the history fetch traversed the same empty range again. Double the work in the worst case rather than the best, which is the opposite of what the comment claimed. It now fetches the latest location *instead of* the history when the window is wide: same single traversal, still narrows the window, and still returns something useful. getLastReports was all-or-nothing. One accessory failing aborted the whole call, so no accessory's updated alignment was persisted, so the next fetch started from the same wide range and failed the same way - it never converged. Each accessory is now isolated, and its alignment is written back even when it returned no reports, since the alignment may still have moved. Calls into Python were not serialised. FindMy.py's synchronous AppleAccount drives a single asyncio event loop, RxJava schedules fetches on a thread pool, and the periodic refresh fires every 60 seconds regardless of whether the previous fetch finished. A fetch slower than the refresh interval is normal for an unaligned accessory, so two threads would call run_until_complete on the same loop and it would fail with "RuntimeError: This event loop is already running" from then on. Co-Authored-By: Claude Opus 5 (1M context) --- .../python/PythonAppleService.java | 20 +- app/src/main/python/main.py | 120 +++++----- app/src/test/python/test_main.py | 207 ++++++++++-------- 3 files changed, 198 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index 679e18d8..741ba0d6 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -35,6 +35,21 @@ public static PythonAppleService getInstance() { return INSTANCE; } + /** + * Serialises all calls into Python. + *
+ * FindMy.py's synchronous AppleAccount wraps an async one and drives it with a single + * asyncio event loop. RxJava schedules our fetches on a thread pool, and the periodic + * refresh in MapsActivity fires every 60 seconds regardless of whether the previous + * fetch has finished. Two fetches overlapping means two threads calling + * run_until_complete on the same loop, which fails with + * "RuntimeError: This event loop is already running" and then keeps failing. + *
+ * A fetch that takes longer than the refresh interval is entirely normal for an + * accessory with no alignment yet, so this is not a rare race. + */ + private static final Object PYTHON_LOCK = new Object(); + private PythonAppleService(PythonAppleAccount account) { this.account = account; } @@ -45,6 +60,7 @@ public Observable getLastReports(final List reque return emptyResult(); } + synchronized (PYTHON_LOCK) { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -61,7 +77,7 @@ public Observable getLastReports(final List reque } return mapResults(returned); - + } }).subscribeOn(Schedulers.io()); } @@ -71,6 +87,7 @@ public Observable getReportsBetween(final List re return emptyResult(); } + synchronized (PYTHON_LOCK) { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -88,6 +105,7 @@ public Observable getReportsBetween(final List re } return mapResults(returned); + } }).subscribeOn(Schedulers.io()); } diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 8fcbfabf..4f5228f3 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -306,58 +306,60 @@ def _filterReportsByTimeRange(reports, startMs, endMs): return out -# Apple accepts at most ~290 hashed keys per request, and FindMy.py walks the key -# index range one step at a time (15 minutes per step for an AirTag). A window wider -# than this is worth paying one cheap probe to avoid. +# Apple accepts at most ~290 hashed keys per request, and FindMy.py walks the key index +# range one step at a time (15 minutes per step for an AirTag). Anything wider than this +# is enough round trips to be worth avoiding. _ALIGNMENT_PROBE_THRESHOLD_INDICES = 2000 -def _narrowAlignmentIfNeeded(account: AppleAccount, accessory: FindMyAccessory, start, end) -> bool: - """ - Collapse a wide key-search window before doing a history fetch. - - The export wizard captures no key alignment record, so a freshly converted - accessory starts at `alignment_date = paired_at, alignment_index = 0`. The key - search then spans the whole life of the tag: a two-year-old AirTag is ~70,000 - indices, which at 290 keys per request is a few hundred round trips to Apple on - the very first fetch after upgrading. That is the account-flagging risk raised in - issue #30. - - `fetch_location` asks for the latest report only, which makes FindMy.py walk - backwards from now and stop at the first hit instead of traversing the entire - range. Alignment is updated as a side effect of any successful fetch, so one cheap - call collapses the window for everything that follows. The updated alignment is - persisted by the caller via updatedAccessoryJson. - - Worst case - a tag with no reports at all - this costs the same traversal the - history fetch would have done anyway, so it is never a net loss. - - :returns: True if a probe was performed. - """ +def _isAlignmentWide(accessory: FindMyAccessory, start, end) -> int: + """Width of the key-index range a history fetch would search, or 0 if unknown.""" try: - width = accessory.get_max_index(end) - accessory.get_min_index(start) + return accessory.get_max_index(end) - accessory.get_min_index(start) except Exception: print(f"Could not determine key index width: {traceback.format_exc()}") - return False + return 0 - if width <= _ALIGNMENT_PROBE_THRESHOLD_INDICES: - return False - print(f"Key search window is {width} indices wide; probing for latest report first " - f"to establish alignment before fetching history.") - try: - account.fetch_location(accessory) - except Exception: - # A failed probe is not fatal - fall through to the full history fetch. - print(f"Alignment probe failed, continuing anyway: {traceback.format_exc()}") - return False +def _fetchReportsForAccessory(account: AppleAccount, accessory: FindMyAccessory, start, end): + """ + Fetch reports for one accessory, avoiding a full-history key search when possible. + + Without a key alignment record an accessory starts at index 0 from its pairing date, + so a history fetch searches the tag's entire life - measured at ~50,000 indices for an + 18-month-old AirTag, which at ~290 keys per request is hundreds of round trips. That is + the account-flagging risk from issue #30. + + When the window is that wide we ask for the latest location *instead of* the history, + not before it. `fetch_location` walks backwards from now and stops at the first hit, and + alignment is updated as a side effect of any successful fetch. So one call both returns + something useful and collapses the window for every fetch that follows. + + Doing it as a probe *before* a history fetch was worse: for a tag with no recent reports + the probe traverses the whole range, finds nothing, narrows nothing, and then the history + fetch traverses it all over again - double the work, in the worst case rather than the + best. Replacing the call avoids that. + """ + width = _isAlignmentWide(accessory, start, end) - try: - narrowed = accessory.get_max_index(end) - accessory.get_min_index(start) - print(f"Key search window narrowed from {width} to {narrowed} indices.") - except Exception: - pass - return True + if width > _ALIGNMENT_PROBE_THRESHOLD_INDICES: + print(f"Key search window is {width} indices wide; fetching latest location only, " + f"to establish alignment without searching the tag's whole history.") + latest = account.fetch_location(accessory) + + narrowed = _isAlignmentWide(accessory, start, end) + if narrowed < width: + print(f"Key search window narrowed from {width} to {narrowed} indices; " + f"subsequent fetches will search the narrow range.") + else: + # No report found anywhere in range. Nothing to align to, and a history fetch + # would search the same empty range again, so don't. + print(f"No reports found for this accessory; leaving alignment untouched and " + f"skipping the history fetch.") + + return [latest] if latest is not None else [] + + return account.fetch_location_history(accessory) def _serializeReports(reports): @@ -415,24 +417,29 @@ def getLastReports( print(f"Fetching report for {beaconId} for the last {hoursBack} hours...") airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) - start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc) now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) - _narrowAlignmentIfNeeded(account, airtag, start_dt, now_dt) - reports = account.fetch_location_history(airtag) - if reports is None: - reports = [] + # Per-accessory isolation. One beacon failing used to abort the whole call, + # which meant no beacon's updated alignment was persisted - so every later + # fetch started from the same wide range again and never converged. + try: + reports = _fetchReportsForAccessory(account, airtag, start_dt, now_dt) or [] + except Exception: + print(f"Fetch failed for {beaconId}, continuing with the rest: " + f"{traceback.format_exc()}") + continue + print(f"Got {len(reports)} raw reports for {beaconId}") filtered = _filterReportsByTimeRange(reports, start_ms, now_ms) print(f" -> {len(filtered)} reports after filtering to last {hoursBack}h") - updated_accessory_json = json.dumps(airtag.to_json()) - res[beaconId] = { "reports": _serializeReports(filtered), - "updatedAccessoryJson": updated_accessory_json, + # Always written, even when there were no reports: the alignment may still + # have moved, and persisting it is what stops the next fetch re-searching. + "updatedAccessoryJson": json.dumps(airtag.to_json()), } return res @@ -469,14 +476,15 @@ def getReports( print(f"Fetching report for {beaconId} in time range {unixStartMs}-{unixEndMs}...") airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) - start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc) end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc) - _narrowAlignmentIfNeeded(account, airtag, start_dt, end_dt) - reports = account.fetch_location_history(airtag) - if reports is None: - reports = [] + try: + reports = _fetchReportsForAccessory(account, airtag, start_dt, end_dt) or [] + except Exception: + print(f"Fetch failed for {beaconId}, continuing with the rest: " + f"{traceback.format_exc()}") + continue print(f"Got {len(reports)} raw reports for {beaconId}") filtered = _filterReportsByTimeRange(reports, unixStartMs, unixEndMs) diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index febeb883..9830c616 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -168,6 +168,8 @@ def test_serializeReports_sorts_chronologically(): # -------------------------------------------------------------------------- class FakeAccessory: + """Reports a key-index width, and can narrow once a fetch has "found" something.""" + def __init__(self, width): self._width = width self.narrowed = False @@ -180,122 +182,74 @@ def get_max_index(self, _dt): class FakeAccount: - def __init__(self, on_fetch=None): + def __init__(self, latest=None, history=None, on_fetch=None): self.fetch_location_calls = 0 + self.fetch_history_calls = 0 + self._latest = latest + self._history = history if history is not None else [] self._on_fetch = on_fetch def fetch_location(self, accessory): self.fetch_location_calls += 1 if self._on_fetch: self._on_fetch(accessory) - return None + return self._latest + + def fetch_location_history(self, accessory): + self.fetch_history_calls += 1 + return self._history -def test_probe_runs_when_the_key_window_is_wide(): - """A two-year-old tag is ~70k indices - exactly the case worth probing.""" - accessory = FakeAccessory(width=70_000) - account = FakeAccount(on_fetch=lambda acc: setattr(acc, "narrowed", True)) +def test_wide_window_fetches_latest_instead_of_history(): + """ + An unaligned tag must not trigger a full-history key search. Asking for the latest + location narrows the window as a side effect and still returns something useful. + """ now = datetime.now(tz=timezone.utc) + report = FakeReport(now) + accessory = FakeAccessory(width=50_000) + account = FakeAccount(latest=report, on_fetch=lambda acc: setattr(acc, "narrowed", True)) - probed = main._narrowAlignmentIfNeeded(account, accessory, now - timedelta(hours=24), now) + out = main._fetchReportsForAccessory(account, accessory, now - timedelta(hours=24), now) - assert probed is True assert account.fetch_location_calls == 1 + assert account.fetch_history_calls == 0, "must not also search the whole history" + assert out == [report] -def test_probe_skipped_when_the_window_is_already_narrow(): - """An aligned accessory must not pay an extra request on every fetch.""" - accessory = FakeAccessory(width=96) # ~1 day for an AirTag - account = FakeAccount() +def test_narrow_window_uses_the_normal_history_fetch(): + """Once aligned, fetches go back to normal - no extra round trip per call.""" now = datetime.now(tz=timezone.utc) + history = [FakeReport(now), FakeReport(now - timedelta(hours=2))] + accessory = FakeAccessory(width=96) # about a day for an AirTag + account = FakeAccount(history=history) - probed = main._narrowAlignmentIfNeeded(account, accessory, now - timedelta(hours=24), now) + out = main._fetchReportsForAccessory(account, accessory, now - timedelta(hours=24), now) - assert probed is False + assert account.fetch_history_calls == 1 assert account.fetch_location_calls == 0 + assert out == history -def test_probe_failure_is_not_fatal(): - """A failed probe must fall through to the normal fetch, not abort it.""" - class ExplodingAccount: - def fetch_location(self, accessory): - raise RuntimeError("anisette server unreachable") - - accessory = FakeAccessory(width=70_000) - now = datetime.now(tz=timezone.utc) - - assert main._narrowAlignmentIfNeeded(ExplodingAccount(), accessory, now - timedelta(hours=24), now) is False - - -# -------------------------------------------------------------------------- -# assertAnisetteIsSupported -# -# Remote is the only provider that works on Android; local needs the unicorn CPU -# emulator, which Chaquopy cannot build. Catching that here means a clear message -# instead of a NotImplementedError from inside the stub package. -# -------------------------------------------------------------------------- - -def _account_blob(anisette): - return json.dumps({"type": "account", "anisette": anisette}) - - -def test_remote_anisette_is_supported(): - blob = _account_blob({"type": "aniRemote", "url": "https://ani.example.com"}) - assert main.assertAnisetteIsSupported(blob) is None - - -def test_local_anisette_is_rejected_with_a_readable_reason(): - blob = _account_blob({"type": "aniLocal"}) - reason = main.assertAnisetteIsSupported(blob) - - assert reason is not None - assert "aniLocal" in reason - assert "remote" in reason.lower() - - -def test_missing_anisette_config_is_rejected(): - reason = main.assertAnisetteIsSupported(json.dumps({"type": "account"})) - assert reason is not None - - -def test_unreadable_account_blob_is_rejected(): - assert main.assertAnisetteIsSupported("not json") is not None - - -def test_getAccount_refuses_local_anisette(): - """The guard must actually be wired into the restore path.""" - assert main.getAccount(_account_blob({"type": "aniLocal"})) is None - - -# -------------------------------------------------------------------------- -# Guard against the test environment drifting from what the app ships -# -------------------------------------------------------------------------- - -def test_pinned_versions_match_the_app_build(): +def test_wide_window_with_no_reports_does_not_then_search_the_history(): """ - These tests only mean anything if they run against the same library version - Chaquopy installs into the APK. Keeps requirements.txt honest. + The regression this design exists to avoid: a tag with no recent reports used to + traverse the entire range for the probe, narrow nothing, then traverse it again for + the history fetch - double the work in the worst case. """ - import re - - gradle = (Path(__file__).resolve().parents[3] / "build.gradle.kts").read_text(encoding="utf-8") - requirements = (Path(__file__).resolve().parent / "requirements.txt").read_text(encoding="utf-8") - - installed = dict(re.findall(r'install\("([A-Za-z_][\w.-]*)==([\d.]+)"\)', gradle)) - required = dict(re.findall(r'^([A-Za-z_][\w.-]*)==([\d.]+)', requirements, re.MULTILINE)) + now = datetime.now(tz=timezone.utc) + accessory = FakeAccessory(width=50_000) # never narrows + account = FakeAccount(latest=None) - assert installed, "could not find any pinned pip installs in build.gradle.kts" + out = main._fetchReportsForAccessory(account, accessory, now - timedelta(hours=24), now) - for package, version in installed.items(): - assert package in required, ( - f"{package} is installed by Chaquopy but not pinned in requirements.txt" - ) - assert required[package] == version, ( - f"{package} is {version} in build.gradle.kts but {required[package]} in requirements.txt" - ) + assert account.fetch_location_calls == 1 + assert account.fetch_history_calls == 0, "must not traverse the same empty range twice" + assert out == [] -def test_probe_survives_an_accessory_that_cannot_report_indices(): +def test_unknown_width_falls_back_to_the_history_fetch(): + """If the width cannot be determined, behave exactly as before this optimisation.""" class BrokenAccessory: def get_min_index(self, _dt): raise ValueError("nope") @@ -303,11 +257,15 @@ def get_min_index(self, _dt): def get_max_index(self, _dt): raise ValueError("nope") - account = FakeAccount() now = datetime.now(tz=timezone.utc) + history = [FakeReport(now)] + account = FakeAccount(history=history) + + out = main._fetchReportsForAccessory(account, BrokenAccessory(), now - timedelta(hours=24), now) + + assert account.fetch_history_calls == 1 + assert out == history - assert main._narrowAlignmentIfNeeded(account, BrokenAccessory(), now - timedelta(hours=24), now) is False - assert account.fetch_location_calls == 0 # -------------------------------------------------------------------------- # Key alignment records @@ -382,3 +340,68 @@ def test_convertPlistToJson_survives_a_corrupt_alignment_record(): # from_plist raises on a malformed alignment plist, so we return None and the caller # retries later rather than storing something wrong. assert result is None + + +# -------------------------------------------------------------------------- +# assertAnisetteIsSupported +# +# Remote is the only provider that works on Android; local needs the unicorn CPU +# emulator, which Chaquopy cannot build. Catching it here means a clear message +# rather than a NotImplementedError from inside the stub package. +# -------------------------------------------------------------------------- + +def _account_blob(anisette): + return json.dumps({"type": "account", "anisette": anisette}) + + +def test_remote_anisette_is_supported(): + blob = _account_blob({"type": "aniRemote", "url": "https://ani.example.com"}) + assert main.assertAnisetteIsSupported(blob) is None + + +def test_local_anisette_is_rejected_with_a_readable_reason(): + reason = main.assertAnisetteIsSupported(_account_blob({"type": "aniLocal"})) + assert reason is not None + assert "aniLocal" in reason + assert "remote" in reason.lower() + + +def test_missing_anisette_config_is_rejected(): + assert main.assertAnisetteIsSupported(json.dumps({"type": "account"})) is not None + + +def test_unreadable_account_blob_is_rejected(): + assert main.assertAnisetteIsSupported("not json") is not None + + +def test_getAccount_refuses_local_anisette(): + """The guard must actually be wired into the restore path.""" + assert main.getAccount(_account_blob({"type": "aniLocal"})) is None + + +# -------------------------------------------------------------------------- +# Guard against the test environment drifting from what the app ships +# -------------------------------------------------------------------------- + +def test_pinned_versions_match_the_app_build(): + """ + These tests only mean anything if they run against the same library version + Chaquopy installs into the APK. Keeps requirements.txt honest. + """ + import re + + gradle = (Path(__file__).resolve().parents[3] / "build.gradle.kts").read_text(encoding="utf-8") + requirements = (Path(__file__).resolve().parent / "requirements.txt").read_text(encoding="utf-8") + + installed = dict(re.findall(r'install\("([A-Za-z_][\w.-]*)==([\d.]+)"\)', gradle)) + required = dict(re.findall(r'^([A-Za-z_][\w.-]*)==([\d.]+)', requirements, re.MULTILINE)) + + assert installed, "could not find any pinned pip installs in build.gradle.kts" + + for package, version in installed.items(): + assert package in required, ( + f"{package} is installed by Chaquopy but not pinned in requirements.txt" + ) + assert required[package] == version, ( + f"{package} is {version} in build.gradle.kts but {required[package]} in requirements.txt" + ) From 86ddc3e73a7de280020c5743ecc187c73ba15d22 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 20:39:47 +0200 Subject: [PATCH 22/50] Give debug builds an inverted launcher icon Debug builds install alongside a real one, and until now both showed the same icon. The label differs, but at a glance on a launcher they are identical - which matters here because the two apps hold different databases, and the real one holds data that cannot be recovered if it is lost. A debug source set overrides just the background drawable, with its colours inverted: the green becomes magenta and the white grid becomes black. The foreground logo is a .webp and is left alone, so the icon still reads as the same app while being unmistakably not the release build. Co-Authored-By: Claude Opus 5 (1M context) --- .../res/drawable/ic_launcher_background.xml | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 app/src/debug/res/drawable/ic_launcher_background.xml diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..f419eac3 --- /dev/null +++ b/app/src/debug/res/drawable/ic_launcher_background.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From bab3ce77bbe438d0d80adf2580c0e5c1f11dc9fc Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 20:58:15 +0200 Subject: [PATCH 23/50] Fix the map buttons properly, and actually invert the debug icon The previous attempt at both was wrong. For the buttons: adding layout_alignParentBottom alongside layout_above does not work as a fallback. RelativeLayout resolves ALIGN_PARENT_BOTTOM after ABOVE, so it wins whenever both are set, and the buttons ended up behind the tag cards instead of above them. The anchor is the real problem: layout_above is ignored when its target is GONE, which is how MapsActivity hid the tag area when there was nothing to show. It now hides it with INVISIBLE, which still occupies its (zero) height, so the anchor keeps resolving and the layout has one unambiguous rule. For the icon: inverting the adaptive icon's background had no visible effect, because the foreground .webp is full-bleed and covers it completely. The foregrounds themselves are now inverted per density into the debug source set, preserving alpha so shape masking still works, with a script to regenerate them if the release icon changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../mipmap-hdpi/ic_launcher_foreground.webp | Bin 0 -> 7770 bytes .../mipmap-mdpi/ic_launcher_foreground.webp | Bin 0 -> 4528 bytes .../mipmap-xhdpi/ic_launcher_foreground.webp | Bin 0 -> 10942 bytes .../mipmap-xxhdpi/ic_launcher_foreground.webp | Bin 0 -> 18094 bytes .../ic_launcher_foreground.webp | Bin 0 -> 25812 bytes .../android/opentagviewer/MapsActivity.java | 8 +- app/src/main/res/layout/activity_maps.xml | 12 +-- scripts/make_debug_launcher_icon.py | 74 ++++++++++++++++++ 8 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp create mode 100644 app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp create mode 100644 app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp create mode 100644 app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp create mode 100644 app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp create mode 100644 scripts/make_debug_launcher_icon.py diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..43dd9d8eb56070e9e42e0ab77542f6040ddfa62b GIT binary patch literal 7770 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 literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..cb24b8d65dbe51a5b9065e8862f8ed69e6c9b0c2 GIT binary patch literal 4528 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 Oq8+_xhwhVV`T+n}ZO!2T literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..ba5e496d9f29cfe0da71bfcaa045a4be64708356 GIT binary patch literal 10942 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 literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..ec13a0e1f1f158a4f6285d36e93a4cbc3648b402 GIT binary patch literal 18094 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 - + None: + with Image.open(source) as image: + image = image.convert("RGBA") + rgb = Image.merge("RGB", image.split()[:3]) + inverted = ImageChops.invert(rgb) + alpha = image.split()[3] + result = Image.merge("RGBA", (*inverted.split(), alpha)) + + destination.parent.mkdir(parents=True, exist_ok=True) + result.save(destination, "WEBP", lossless=True) + + +def main() -> int: + written = 0 + for density in DENSITIES: + source = MAIN_RES / f"mipmap-{density}" / ICON_NAME + if not source.is_file(): + print(f" skipping {density}: no source icon") + continue + + destination = DEBUG_RES / f"mipmap-{density}" / ICON_NAME + invert_preserving_alpha(source, destination) + print(f" wrote {destination.relative_to(REPO_ROOT)}") + written += 1 + + if written == 0: + print("No icons written - is the source path right?", file=sys.stderr) + return 1 + + print(f"\nInverted {written} icon(s) into app/src/debug/res/.") + print("Re-run this if the release icon ever changes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9b75fea75b3c951bd76e1b160a2d2aa641f3ec42 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sat, 8 Aug 2026 21:15:14 +0200 Subject: [PATCH 24/50] Capitalise the password field label Every other field label on the login screen is title case ("Email or Phone Number"), but this one read "password". Fixed in the locales where case applies; German was already correct, and the CJK locales have no case. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/res/values-en/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5b4d9e5d..0e22d57c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -31,7 +31,7 @@ Anisette server at %1$s could not be reached! Login failed: %1$s Email or Phone Number - password + Password Apple Account Login Welcome ! diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0d9ec88e..63c02405 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -31,7 +31,7 @@ Le serveur Anisette à %1$s n\'a pas pu être atteint ! La connexion a échoué: %1$s Email ou Numéro de Téléphone - mot de passe + Mot de passe Compte Apple Connexion Bienvenue ! diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3778e874..edeabdfb 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -31,7 +31,7 @@ Anisette server %1$s kon niet bereikt worden! Inloggen mislukt: %1$s Email of Telefoonnummer - wachtwoord + Wachtwoord Apple Account Inloggen Welkom ! diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index be65a9d6..e99a9fde 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -31,7 +31,7 @@ Не удалось связаться с сервером Anisette по адресу %1$s! Ошибка входа: %1$s Имейл или Номер Телефона - пароль + Пароль Аккаунт Apple Вход Добро Пожаловать ! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a72fbef..5c3db6a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,7 +48,7 @@ Welcome ! Login failed: %1$s Email or Phone Number - password + Password Apple Account Login SMS (%1$s) From 01d88fd809ee225b98f7f50a6018c3ae190b5454 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 9 Aug 2026 12:23:29 +0200 Subject: [PATCH 25/50] Fix the tag cards and keep them clear of the navigation bar Three regressions from the fork merge, all on the map screen. The text column sat 51dp too far right and was that much too narrow. layout_weight on the icon container had gone from 1 to 0, which looks cosmetic but is not: the sibling text column is match_parent, so the row always measures 102dp over budget and the 1:1 split took 51dp off each. Cards no longer all had the same height, because they size to their own content and an address that wraps to two lines makes one taller than its neighbours. MapsActivity now forces MATCH_PARENT on each card, which triggers LinearLayout.forceUniformHeight and measures them all to the tallest. The button row rendered behind the navigation bar. MapsActivity goes edge to edge and has no inset handling; that was previously hidden by the tag area's fixed 240dp height, which left ~70dp of slack below the card that absorbed the bar by accident. Gare changed that area and both card rows to wrap_content, so the card now hugs its content exactly. Rather than restoring the magic 240dp, WindowPaddingUtil gained a bottom-inset helper alongside the existing top one. Invisible on gesture navigation, ~48dp on three-button. clipChildren/clipToPadding are off on the scroll view so the cards' 8dp elevation shadow can still draw into that padding. Co-Authored-By: Claude Opus 5 --- .../ui/compat/WindowPaddingUtil.java | 24 ++++++++++++++ app/src/main/res/layout/activity_maps.xml | 33 +++++++++++++++++++ app/src/main/res/layout/maps_tag_card.xml | 17 +++++++--- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java index a973ee26..18ecffe9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java @@ -29,4 +29,28 @@ public static void insertUITopPadding(View rootView) { return insets; }); } + + /** + * Keeps a bottom-anchored view clear of the navigation bar. + * + *

Activities that go edge to edge with + * {@code WindowCompat.setDecorFitsSystemWindows(window, false)} draw underneath the + * navigation bar, so anything aligned to the parent's bottom edge ends up behind it. That is + * invisible on gesture navigation, where the inset is only a few dp, and obvious on + * three-button navigation, where it is around 48dp. + * + * @param view The bottom-anchored view to pad. + */ + public static void insertUIBottomPadding(View view) { + ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding( + v.getPaddingLeft(), + v.getPaddingTop(), + v.getPaddingRight(), + systemBars.bottom + ); + return insets; + }); + } } diff --git a/app/src/main/res/layout/activity_maps.xml b/app/src/main/res/layout/activity_maps.xml index 14289f72..219f16ac 100644 --- a/app/src/main/res/layout/activity_maps.xml +++ b/app/src/main/res/layout/activity_maps.xml @@ -16,6 +16,10 @@ android:background="@color/md_theme_background" tools:context=".MapsActivity" /> + + + + + + android:layout_gravity="center" + android:layout_weight="1"> Date: Sun, 9 Aug 2026 12:25:13 +0200 Subject: [PATCH 26/50] Fetch one accessory at a time and stop refreshes queueing up Python was handed the whole accessory list and returned a single dict at the end, so the updated key alignment for every accessory was written in one go. A tag with no alignment record takes minutes to resolve - measured at ~175 sequential requests for a 50,000-index search - so quitting part-way through discarded the work for all of them, including the ones that had already finished, and the next launch searched the same range again. Java now drives the loop and stores each result as it arrives, which also lets the map update per tag and keeps one failure from taking the rest of the batch with it. Serialising calls into Python fixed the event-loop crash but turned it into a backlog: the 60-second refresh kept firing during a long fetch, and each tick blocked on the lock rather than being dropped, so a queue grew by one a minute and then fired all at once. PYTHON_LOCK is now a ReentrantLock exposing isBusy(), and the periodic path skips its turn - a refresh that is minutes late has no value. A fetch running longer than six seconds now shows a banner saying which tag it is on and asking the user to keep the app open, because the work is lost if they do not. Six seconds so an ordinary refresh never flashes it. Co-Authored-By: Claude Opus 5 --- .../android/opentagviewer/MapsActivity.java | 167 +++++++++++++++++- .../python/PythonAppleService.java | 94 ++++++---- 2 files changed, 220 insertions(+), 41 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 8ce461cf..2e9e8030 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -27,11 +27,13 @@ import android.net.Uri; import android.os.Bundle; import android.os.Handler; +import android.os.Looper; import android.text.format.DateUtils; import android.util.Log; import android.util.Pair; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.HorizontalScrollView; import android.widget.ImageButton; @@ -47,6 +49,7 @@ import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.ui.maps.IMapProvider; import dev.wander.android.opentagviewer.ui.maps.MapProviderFactory; import dev.wander.android.opentagviewer.ui.maps.GoogleMapProvider; @@ -68,6 +71,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import dev.wander.android.opentagviewer.data.model.BeaconInformation; @@ -85,6 +89,7 @@ import dev.wander.android.opentagviewer.db.repo.BeaconRepository; import dev.wander.android.opentagviewer.db.repo.model.ImportData; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.PythonAppleService; import dev.wander.android.opentagviewer.python.PythonAccountLoginException; import dev.wander.android.opentagviewer.python.PythonAuthService; @@ -165,6 +170,22 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa private final Handler refreshSchedulerHandler = new Handler(); private Runnable nextLocationRefreshTask = null; + /** + * How long a fetch may run before we tell the user it is still working. + *
+ * Long enough that an ordinary refresh never flashes the banner, short enough that a + * first fetch does not sit there looking frozen. + */ + private static final long SHOW_LONG_FETCH_BANNER_AFTER_MS = 6000L; + + private final Handler longFetchBannerHandler = new Handler(Looper.getMainLooper()); + private final Runnable showLongFetchBanner = () -> this.setLongFetchBannerVisible(true); + + /** All three are only ever touched on the main looper, so they need no synchronisation. */ + private int fetchesInFlight = 0; + private int fetchProgressDone = 0; + private int fetchProgressTotal = 0; + private Optional lastCameraPositionOnLoad; private int windowWidth = 0; @@ -203,6 +224,11 @@ public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMa if (data != null && data.getBooleanExtra("isDeviceListChanged", false)) { this.handleDeviceListChanged(); } + // The empty state on the device list offers to import, but the picker and + // everything that runs after it live here, so it asks us to start it. + if (data != null && data.getBooleanExtra("startImport", false)) { + this.handleImport(); + } } } ); @@ -353,6 +379,8 @@ protected void onPause() { refreshSchedulerHandler.removeCallbacks(this.nextLocationRefreshTask); this.nextLocationRefreshTask = null; } + + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); // 调用高德地图的生命周期方法 if (this.mapProvider instanceof AMapProvider) { @@ -419,6 +447,14 @@ private void refreshIfAllowed() { return; } + if (PythonAppleService.isBusy()) { + // A first fetch for an accessory with no key alignment can run for minutes, so + // this tick would otherwise block on the lock rather than be skipped, and the + // backlog would grow by one every minute until the fetch finished. + Log.d(TAG, "Skipping scheduled refresh: a fetch is still in progress"); + return; + } + final long now = System.currentTimeMillis(); if (now < this.last24HHistoryFetchAt + WAIT_BEFORE_REFETCH) { Log.d(TAG, String.format( @@ -1057,6 +1093,12 @@ private synchronized void showBeaconOnMap(final BeaconInformation beacon, final private void setupTagScrollArea() { HorizontalScrollView scrollContainer = this.findViewById(R.id.tags_scrollable_area); + + // The cards size themselves to their content now, so nothing absorbs the navigation bar + // any more. This used to be hidden by the area's fixed 240dp height, which held about + // 70dp of slack below the card. + WindowPaddingUtil.insertUIBottomPadding(scrollContainer); + this.tagListSwiperHelper = new TagListSwiperHelper( scrollContainer, this.dynamicCardsForTag, @@ -1149,6 +1191,13 @@ private synchronized void updateBeaconCards() { var params = v.getLayoutParams(); params.width = this.windowWidth != 0 ? (this.windowWidth - 80) : (this.getWindow().getDecorView().getWidth() - 80); + // Cards size themselves to their own content, so a two-line address made one card + // taller than its neighbours. Inflating with a null root drops the layout's own + // height, and the container hands out WRAP_CONTENT by default, so it has to be set + // here: MATCH_PARENT inside a wrap_content horizontal LinearLayout triggers + // forceUniformHeight, which measures every card to the height of the tallest. + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + v.setLayoutParams(params); // the title @@ -1249,23 +1298,131 @@ private Observable>> fetchLastReports(fin Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) - .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) + .doOnSubscribe(__ -> this.markFetchStarted()) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) .doOnNext(reports -> this.last24HHistoryFetchAt = now) // on success, update this time. - .flatMap(this.beaconRepo::storeFetchResult); + .doFinally(this::markFetchFinished); } private Observable>> fetchLastReports(final Map beaconIdToPlist, final int hoursToGoBack) { Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) - .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) - .flatMap(this.beaconRepo::storeFetchResult); + .doOnSubscribe(__ -> this.markFetchStarted()) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) + .doFinally(this::markFetchFinished); + } + + /** + * Fetches each accessory in its own call into Python, storing the result before moving on. + *
+ * Handing Python the whole list meant it returned a single dict at the very end, so the + * updated key alignment for every accessory was written in one go. A tag with no alignment + * record takes minutes to resolve, and quitting part-way through discarded the work for + * all of them - including the ones that had already finished - so the next launch searched + * the same tens of thousands of key indices again. One call per accessory means each one + * is persisted as soon as it resolves. + *
+ * It also lets the UI update per tag instead of all at once, and keeps a single failure + * from taking the rest of the batch with it. + *
+ * Sequential on purpose: FindMy.py's synchronous account drives one asyncio event loop, + * and calls into Python are serialised anyway (see PythonAppleService). concatMap makes + * that explicit rather than leaving threads queued on a lock. + */ + private Observable>> fetchOneAccessoryAtATime( + final List requests, final int hoursToGoBack) { + + final int total = requests.size(); + final AtomicInteger completed = new AtomicInteger(0); + this.setLongFetchProgress(0, total); + + return Observable.fromIterable(requests) + .concatMap(request -> this.appleService.getLastReports(List.of(request), hoursToGoBack) + .flatMap(this.beaconRepo::storeFetchResult) + .doOnError(error -> Log.e(TAG, + "Failed to fetch reports for beaconId=" + request.getBeaconId() + + "; continuing with the remaining accessories", error)) + .onErrorResumeNext(__ -> Observable.empty()) + .doOnComplete(() -> this.setLongFetchProgress(completed.incrementAndGet(), total))); } private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) { Log.i(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); return this.beaconRepo.toAccessoryRequests(Map.of(beaconId, pList)) + .doOnSubscribe(__ -> this.markFetchStarted()) .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) - .flatMap(this.beaconRepo::storeFetchResult); + .flatMap(this.beaconRepo::storeFetchResult) + .doFinally(this::markFetchFinished); + } + + /** + * Starts the clock on the "still working" banner. + *
+ * A tag whose export carried no KeyAlignmentRecord starts at index 0 from its pairing + * date, so its first fetch searches the tag's whole life - tens of thousands of key + * indices, at roughly 290 per request. That is minutes of sequential requests during + * which nothing changes on screen, and it is indistinguishable from a hang. + *
+ * It also matters that the user does not walk away: Python returns the updated + * alignment for every accessory in one dict at the end of the batch, so quitting + * part-way through discards the work for all of them and the next launch starts over. + */ + private void markFetchStarted() { + this.longFetchBannerHandler.post(() -> { + if (this.fetchesInFlight++ == 0) { + // Clear the previous run's counts so a stale "3 of 5" cannot flash up. + this.fetchProgressDone = 0; + this.fetchProgressTotal = 0; + this.longFetchBannerHandler.postDelayed( + this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); + } + }); + } + + private void markFetchFinished() { + this.longFetchBannerHandler.post(() -> { + this.fetchesInFlight = Math.max(0, this.fetchesInFlight - 1); + if (this.fetchesInFlight > 0) { + return; + } + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); + this.setLongFetchBannerVisible(false); + }); + } + + /** Records how far through the batch we are, so the banner can say so. */ + private void setLongFetchProgress(int done, int total) { + this.longFetchBannerHandler.post(() -> { + this.fetchProgressDone = done; + this.fetchProgressTotal = total; + + TextView banner = this.findViewById(R.id.long_fetch_banner); + if (banner != null && banner.getVisibility() == VISIBLE) { + banner.setText(this.longFetchBannerText()); + } + }); + } + + private String longFetchBannerText() { + if (this.fetchProgressTotal <= 1) { + // "1 of 1" tells the user nothing they cannot already see. + return this.getString(R.string.resolving_tags_banner); + } + return this.getString( + R.string.resolving_tags_banner_progress, + Math.min(this.fetchProgressDone + 1, this.fetchProgressTotal), + this.fetchProgressTotal); + } + + private void setLongFetchBannerVisible(boolean visible) { + TextView banner = this.findViewById(R.id.long_fetch_banner); + if (banner == null) { + return; + } + if (visible) { + banner.setText(this.longFetchBannerText()); + } + banner.setVisibility(visible ? VISIBLE : GONE); } private boolean isAppleServiceInitialised() { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index 741ba0d6..5b857913 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -12,6 +12,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import io.reactivex.rxjava3.core.Observable; @@ -47,8 +48,23 @@ public static PythonAppleService getInstance() { *
* A fetch that takes longer than the refresh interval is entirely normal for an * accessory with no alignment yet, so this is not a rare race. + *
+ * Serialising alone is not enough: the periodic refresh would still queue up behind a + * slow fetch, one entry per minute, and then fire the whole stale backlog at once when + * it finally drained. Callers on the periodic path should check {@link #isBusy()} and + * skip their turn instead - a refresh that is minutes late has no value. + */ + private static final ReentrantLock PYTHON_LOCK = new ReentrantLock(); + + /** + * Whether a call into Python is currently in progress. + *
+ * Advisory only. A caller that acts on this can still be beaten to the lock, which is + * harmless: it just waits, exactly as it did before. */ - private static final Object PYTHON_LOCK = new Object(); + public static boolean isBusy() { + return PYTHON_LOCK.isLocked(); + } private PythonAppleService(PythonAppleAccount account) { this.account = account; @@ -60,23 +76,26 @@ public Observable getLastReports(final List reque return emptyResult(); } - synchronized (PYTHON_LOCK) { - var py = Python.getInstance(); - var module = py.getModule(MODULE_MAIN); - - var returned = module.callAttr( - "getLastReports", - new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToAccessoryData", requests), - new Kwarg("hoursBack", hoursToGoBack) - ); - - if (returned == null) { - Log.e(TAG, "python call to getLastReports resulted in error (check python logs for details)"); - throw new PythonAppleFindMyException("Error while retrieving last reports for account via python!"); - } - - return mapResults(returned); + PYTHON_LOCK.lock(); + try { + var py = Python.getInstance(); + var module = py.getModule(MODULE_MAIN); + + var returned = module.callAttr( + "getLastReports", + new Kwarg("account", this.account.getAccountObj()), + new Kwarg("idToAccessoryData", requests), + new Kwarg("hoursBack", hoursToGoBack) + ); + + if (returned == null) { + Log.e(TAG, "python call to getLastReports resulted in error (check python logs for details)"); + throw new PythonAppleFindMyException("Error while retrieving last reports for account via python!"); + } + + return mapResults(returned); + } finally { + PYTHON_LOCK.unlock(); } }).subscribeOn(Schedulers.io()); } @@ -87,24 +106,27 @@ public Observable getReportsBetween(final List re return emptyResult(); } - synchronized (PYTHON_LOCK) { - var py = Python.getInstance(); - var module = py.getModule(MODULE_MAIN); - - var returned = module.callAttr( - "getReports", - new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToAccessoryData", requests), - new Kwarg("unixStartMs", startTimeUnixMS), - new Kwarg("unixEndMs", endTimeUnixMS) - ); - - if (returned == null) { - Log.e(TAG, "python call to getReports resulted in error (check python logs for details)"); - throw new PythonAppleFindMyException("Error while retrieving time ranged reports for account via python!"); - } - - return mapResults(returned); + PYTHON_LOCK.lock(); + try { + var py = Python.getInstance(); + var module = py.getModule(MODULE_MAIN); + + var returned = module.callAttr( + "getReports", + new Kwarg("account", this.account.getAccountObj()), + new Kwarg("idToAccessoryData", requests), + new Kwarg("unixStartMs", startTimeUnixMS), + new Kwarg("unixEndMs", endTimeUnixMS) + ); + + if (returned == null) { + Log.e(TAG, "python call to getReports resulted in error (check python logs for details)"); + throw new PythonAppleFindMyException("Error while retrieving time ranged reports for account via python!"); + } + + return mapResults(returned); + } finally { + PYTHON_LOCK.unlock(); } }).subscribeOn(Schedulers.io()); } From a268a126c1bf5980c0d2aaf66ae489b0b46b9f4d Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 9 Aug 2026 12:41:42 +0200 Subject: [PATCH 27/50] Actually import KeyAlignmentRecords, and test that it happens The entire 0.0.2 alignment path was dead code. There was a FILE_TYPE constant, a case in the switch, a keyAlignmentRecords map, an alignment_plist column and a Room migration to add it - but no entry for KEY_ALIGNMENT_RECORD in MATCHERS, which getAllowedFileType is the only thing that iterates. Every KeyAlignmentRecords entry therefore returned null, was logged as an unexpected file, and was dropped. Nothing failed. Imports just silently kept searching each tag's whole key history, which is the ~50,000-index problem the feature exists to solve. It survived because no real 0.0.2 zip has ever been produced, and the on-device testing exercised the fallback probe, which works without a record. AppleZipImporterUtil had no tests at all. It now has four: a 0.0.2 zip populates alignmentPlist; records key off the parent directory rather than the record uuid, so two tags cannot get each other's; legacy 0.0.1 zips still import with null; and a record for an unknown accessory is ignored. Reverting the matcher fails two of them. On the wizard side, three tests pin the other half of the contract: KeyAlignmentRecords is in WHITELISTED_DIRS, make_output_path preserves the / nesting and rewrites .record to .plist, and decrypt_folder writes a decryptable record there. The path assertion checks against a copy of the Android matcher, so if either side drifts a test fails instead of records silently vanishing again. Also fixes _create_plist, annotated as returning bytes while returning a tuple, which accounted for five of the wizard's pyright errors, and drops a duplicate pytest import that would have failed lint. Co-Authored-By: Claude Opus 5 --- .../util/parse/AppleZipImporterUtilTest.java | 221 ++++++++++++++++++ .../util/parse/AppleZipImporterUtil.java | 5 +- python/test/test_airtag_decryptor.py | 108 ++++++++- 3 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java new file mode 100644 index 00000000..4997b93f --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java @@ -0,0 +1,221 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.net.Uri; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.db.repo.model.ImportData; + +/** + * Covers the zip parsing stage of an import, and in particular whether a KeyAlignmentRecord + * survives the trip from the zip into OwnedBeacon.alignmentPlist. + *
+ * This was previously untested, and the whole 0.0.2 path was silently dead: the file type + * enum, the switch case and the database column all existed, but there was no entry for + * KEY_ALIGNMENT_RECORD in the pattern map, so every alignment record was logged as an + * unexpected file and dropped. Nothing failed - imports just quietly kept searching each + * tag's entire key history. + *
+ * These tests deliberately do not exercise plist to accessory-JSON conversion. That call goes + * through Chaquopy and is covered by the pytest suite for main.py; the importer catches its + * failures and leaves accessoryJson null for the lazy backfill to retry, so it does not need + * a Python runtime here. + */ +@RunWith(AndroidJUnit4.class) +public class AppleZipImporterUtilTest { + + /** Uppercase v4 UUIDs - the matchers are case sensitive, as macOS writes them uppercase. */ + private static final String BEACON_A = "0FB0AEAC-C083-405E-A979-4AA6A73F5C56"; + private static final String BEACON_B = "46E881A1-3CD9-4965-AEA9-2D95414661E7"; + private static final String RECORD_ID = "19FDE267-DE22-4B9A-BC44-E22C5970FCDC"; + + private static final String YAML_0_0_2 = + "version: 0.0.2\n" + + "exportTimestamp: 1740685990163\n" + + "via: test\n" + + "sourceUser: tester\n"; + + private static final String YAML_0_0_1 = + "version: 0.0.1\n" + + "exportTimestamp: 1740685990163\n" + + "via: test\n" + + "sourceUser: tester\n"; + + private Context context; + private File zipFile; + + @Before + public void setUp() { + this.context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @After + public void tearDown() { + if (this.zipFile != null && this.zipFile.exists() && !this.zipFile.delete()) { + this.zipFile.deleteOnExit(); + } + } + + @Test + public void readsTheAlignmentRecordFromA002Export() throws IOException { + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_A + "/" + RECORD_ID + ".plist", alignmentPlist(4321)); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + OwnedBeacon beacon = result.getOwnedBeacons().get(0); + + assertEquals(BEACON_A, beacon.id); + assertNotNull( + "The alignment record was dropped during import - check the KEY_ALIGNMENT_RECORD" + + " entry in AppleZipImporterUtil.MATCHERS", + beacon.alignmentPlist); + assertTrue( + "Expected the accessory's own alignment record, got: " + beacon.alignmentPlist, + beacon.alignmentPlist.contains("lastIndexObserved")); + } + + @Test + public void keysTheAlignmentRecordByAccessoryNotByRecordId() throws IOException { + // The record's own uuid appears in the filename too. Keying by it instead of by the + // parent directory would attach every record to the wrong accessory, or to none. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_A + "/" + RECORD_ID + ".plist", alignmentPlist(1111)); + + entries.put("OwnedBeacons/" + BEACON_B + ".plist", ownedBeaconPlist(BEACON_B)); + entries.put("BeaconNamingRecord/" + BEACON_B + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag B")); + entries.put("KeyAlignmentRecords/" + BEACON_B + "/" + RECORD_ID + ".plist", alignmentPlist(2222)); + + ImportData result = importZipOf(entries); + + assertEquals(2, result.getOwnedBeacons().size()); + for (OwnedBeacon beacon : result.getOwnedBeacons()) { + assertNotNull("No alignment record for " + beacon.id, beacon.alignmentPlist); + + final String expectedIndex = BEACON_A.equals(beacon.id) ? "1111" : "2222"; + assertTrue( + "Alignment record for " + beacon.id + " has the wrong index: " + + beacon.alignmentPlist, + beacon.alignmentPlist.contains(expectedIndex)); + } + } + + @Test + public void legacyExportsImportWithoutAnAlignmentRecord() throws IOException { + // Exports made before 0.0.2 carry no KeyAlignmentRecords at all. They must still + // import; the column stays null and main.py falls back to probing for the alignment. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_1); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + assertNull(result.getOwnedBeacons().get(0).alignmentPlist); + assertEquals("0.0.1", result.getOwnedBeacons().get(0).version); + } + + @Test + public void ignoresAnAlignmentRecordForAnUnknownAccessory() throws IOException { + // macOS can hold a KeyAlignmentRecord for an accessory that is no longer in + // OwnedBeacons. It has nothing to attach to and must not affect the rest of the import. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_B + "/" + RECORD_ID + ".plist", alignmentPlist(9999)); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + assertEquals(BEACON_A, result.getOwnedBeacons().get(0).id); + assertNull(result.getOwnedBeacons().get(0).alignmentPlist); + } + + // ----------------------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------------------- + + private ImportData importZipOf(final Map entries) throws IOException { + this.zipFile = File.createTempFile("otv-import-test", ".zip", this.context.getCacheDir()); + + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(this.zipFile))) { + for (Map.Entry entry : entries.entrySet()) { + out.putNextEntry(new ZipEntry(entry.getKey())); + out.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + } + + return new AppleZipImporterUtil(this.context).extractZip(Uri.fromFile(this.zipFile)); + } + + /** + * Shaped like a real OwnedBeacons plist but carrying no key material. The importer only + * stores this verbatim - parsing it is main.py's job and is covered by its own tests. + */ + private static String ownedBeaconPlist(final String beaconId) { + return "\n" + + "\n" + + "\n" + + "\tidentifier\n" + + "\t" + beaconId + "\n" + + "\tpairingDate\n" + + "\t2025-02-27T20:33:10Z\n" + + "\n" + + "\n"; + } + + private static String namingRecordPlist(final String name) { + return "\n" + + "\n" + + "\n" + + "\tname\n" + + "\t" + name + "\n" + + "\n" + + "\n"; + } + + /** The two fields that matter: where the rolling key index was, and when. */ + private static String alignmentPlist(final int lastIndexObserved) { + return "\n" + + "\n" + + "\n" + + "\tlastIndexObserved\n" + + "\t" + lastIndexObserved + "\n" + + "\tlastIndexObservationDate\n" + + "\t2026-08-01T12:00:00Z\n" + + "\n" + + "\n"; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java index cb9cdbb5..18f1207d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java @@ -70,7 +70,10 @@ enum FILE_TYPE { private static final Map MATCHERS = Map.of( FILE_TYPE.EXPORT_INFO, Pattern.compile("^OPENTAGVIEWER\\.yml$"), FILE_TYPE.OWNED_BEACON, Pattern.compile("^OwnedBeacons/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$"), - FILE_TYPE.BEACON_NAMING_RECORD, Pattern.compile("^BeaconNamingRecord/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$") + FILE_TYPE.BEACON_NAMING_RECORD, Pattern.compile("^BeaconNamingRecord/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$"), + // Same nesting as BeaconNamingRecord: /. The wizard + // rewrites the .record extension macOS uses to .plist on export. + FILE_TYPE.KEY_ALIGNMENT_RECORD, Pattern.compile("^KeyAlignmentRecords/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$") ); private static final String X_PATH_TO_CLOUDKIT_METADATA = "/plist/dict/key[.='cloudKitMetadata']/following-sibling::data[1]"; diff --git a/python/test/test_airtag_decryptor.py b/python/test/test_airtag_decryptor.py index 33c8bee8..2a74dfbd 100644 --- a/python/test/test_airtag_decryptor.py +++ b/python/test/test_airtag_decryptor.py @@ -1,21 +1,23 @@ from dataclasses import dataclass -import time import os +import re import shutil +import time import uuid -import pytest from unittest.mock import Mock import plistlib +import pytest from Crypto.Cipher import AES from Crypto.Random import get_random_bytes -import pytest from test.unittestutils import DIRNAME, skip_unless_macos_le14, skip_unless_unix from main.airtag_decryptor import ( AbstractSubprocessRunner, + KEY_ALIGNMENT_RECORDS, KeyStoreKeyNotFoundException, + WHITELISTED_DIRS, decrypt_folder, decrypt_plist, dump_plist, @@ -82,7 +84,7 @@ def _make_output(gena_value: str) -> str: """ -def _create_plist(plistData: dict, key: bytes | None = None) -> bytes: +def _create_plist(plistData: dict, key: bytes | None = None) -> tuple[bytes, bytes]: if key is None: key = get_random_bytes(16) @@ -403,3 +405,101 @@ def setup(): if os.path.exists(tmp_output_to): shutil.rmtree(tmp_output_to) + + +# --------------------------------------------------------------------------------------- +# KeyAlignmentRecords +# +# These records are what stop a freshly imported tag searching its entire key history on the +# first fetch (see issue #30). They were originally left out of WHITELISTED_DIRS, so exports +# never carried them and nothing failed - imports were just enormously more expensive than +# they needed to be. The tests below pin both halves of that: that the folder is exported at +# all, and that it lands at the exact path the Android importer looks for. +# --------------------------------------------------------------------------------------- + +# Must stay in step with FILE_TYPE.KEY_ALIGNMENT_RECORD in +# app/src/main/java/.../util/parse/AppleZipImporterUtil.java. If this test and that matcher +# ever disagree, exports silently lose their alignment records again. +ANDROID_KEY_ALIGNMENT_MATCHER = re.compile( + r"^KeyAlignmentRecords/" + r"([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/" + r"([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\.plist$" +) + + +def _uppercase_uuid4() -> str: + return str(uuid.uuid4()).upper() + + +def test_key_alignment_records_are_exported(): + assert KEY_ALIGNMENT_RECORDS in WHITELISTED_DIRS + + +def test_make_output_path_keeps_the_key_alignment_nesting(): + # macOS stores these as KeyAlignmentRecords//.record. The accessory id + # only exists as the parent directory name - unlike naming records, the file itself has no + # "associatedBeacon" field - so flattening the path would lose the association entirely. + input_root = "/Users/someone/Library/com.apple.icloud.searchpartyd" + accessory_id = _uppercase_uuid4() + record_id = _uppercase_uuid4() + + result = make_output_path( + "/Users/someone/export", + f"{input_root}/{KEY_ALIGNMENT_RECORDS}/{accessory_id}/{record_id}.record", + input_root + ) + + rel_path = os.path.relpath(result, "/Users/someone/export").replace(os.sep, "/") + + assert rel_path == f"{KEY_ALIGNMENT_RECORDS}/{accessory_id}/{record_id}.plist" + + match = ANDROID_KEY_ALIGNMENT_MATCHER.match(rel_path) + assert match is not None, f"the Android importer would skip {rel_path}" + assert match.group(1) == accessory_id + + +def test_decrypt_folder_writes_key_alignment_records(): + alignment = { + "lastIndexObserved": 4321, + "lastIndexObservationDate": "2026-08-01T12:00:00Z", + } + key = get_random_bytes(16) + + accessory_id = _uppercase_uuid4() + record_id = _uppercase_uuid4() + + base_path = os.path.join(DIRNAME, f"resources/{_uppercase_uuid4()}") + record_path = os.path.join( + base_path, KEY_ALIGNMENT_RECORDS, accessory_id, f"{record_id}.record") + tmp_output_to = _create_tmp_out_folder() + + try: + os.makedirs(os.path.dirname(record_path), exist_ok=True) + with open(record_path, 'wb') as f: + f.write(_create_plist(alignment, key)[0]) + + # test target: + decrypt_folder( + base_path, + KEY_ALIGNMENT_RECORDS, + key, + tmp_output_to + ) + + expected = os.path.join( + tmp_output_to, KEY_ALIGNMENT_RECORDS, accessory_id, f"{record_id}.plist") + + assert os.path.isfile(expected), ( + f"expected a decrypted alignment record at {expected}") + + with open(expected, 'rb') as f: + written = plistlib.load(f) + + assert written["lastIndexObserved"] == 4321 + + finally: + if os.path.exists(base_path): + shutil.rmtree(base_path) + + if os.path.exists(tmp_output_to): + shutil.rmtree(tmp_output_to) From 4ab68646e1df6d8f525a9688500fd26ac9a53bd6 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 9 Aug 2026 12:42:10 +0200 Subject: [PATCH 28/50] Run instrumented tests on a Gradle managed device connectedAndroidTest against a hand-started emulator is unreliable in a way that looks like a code problem: the Android Gradle Plugin holds its ADB connection inside the Gradle daemon and reuses it across invocations, so once the emulator's adb daemon goes stale the next run fails to install or hangs. Recovering needs both an emulator restart and ./gradlew --stop, because restarting only the emulator leaves the daemon holding the dead bridge. A managed device is created and destroyed per run, so nothing survives to go stale. ./gradlew :app:testEmulatorDebugAndroidTest Verified from cold: 24/24 in 1m37s including downloading the API 34 ATD system image, with no manual setup. testAllOnDevice now uses it, so it no longer needs an emulator booted first. It is also the only form of this that CI can run unattended. The aosp-atd image has no Play Services, so a test that needs Maps would have to move to a google image. Nothing does today. scripts/run_instrumented_tests.sh stays as a fallback for running against an emulator you already have open. It pins ANDROID_SERIAL so a run cannot install to a physical phone, and restarts both halves if a run wedges. AppCryptographyUtilTest asserted the app's entire AndroidKeyStore held exactly one alias, which is only true on a device that has never signed in - signing in leaves KEYSTORE_ALIAS_ACCOUNT behind permanently, so the test passed once and failed forever after. It now asserts its own alias is present, and deletes it before and after: keystore entries outlive the process and the test run, so a crashed run would otherwise leave a key the next run silently reused. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 47 ++++ .../util/AppCryptographyUtilTest.java | 40 ++- build.gradle.kts | 11 +- scripts/run_instrumented_tests.sh | 236 ++++++++++++++++++ 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 scripts/run_instrumented_tests.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8386a59..4fbd6215 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,31 @@ android { androidResources { // generateLocaleConfig = true } + + testOptions { + managedDevices { + localDevices { + // Gradle provisions, boots, and tears down this emulator itself. + // + // Running against an emulator you started by hand is unreliable: the Android + // Gradle Plugin holds its ADB connection inside the Gradle daemon and reuses + // it between invocations, so once the emulator's adb daemon goes stale the + // next run fails to install or hangs, with an error that has nothing to do + // with the code. A managed device is created fresh per run, so there is no + // connection left over to go stale. It is also what CI can run unattended. + // + // aosp-atd is a stripped-down image built for tests: no Play Services, and no + // Maps as a result. Fine here, because none of the instrumented tests start + // MapsActivity - they cover the database migrations, the repositories and the + // keystore. Anything that needs Maps has to move to a "google" image. + create("testEmulator") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + } + } } lombok { @@ -182,6 +207,28 @@ val generateUnicornStubWheel by tasks.registering(Exec::class) { tasks.matching { it.name.contains("PythonRequirements") || it.name.contains("PythonReqs") } .configureEach { dependsOn(generateUnicornStubWheel) } +// Deliberately NOT wired into assembleDebug/assembleRelease. +// +// Its output is committed source, so running it on every build would either dirty the working +// tree whenever somebody pushes a commit or changes their GitHub avatar, or make an offline +// build fail. Refreshing it is a repository event, not a build event: the scheduled workflow +// in .github/workflows/update-contributors.yml runs this and opens a PR when the list moves. +// Run it by hand any time with ./gradlew updateContributors. +val updateContributors by tasks.registering(Exec::class) { + group = "build" + description = "Regenerates the contributor list and avatars bundled into the Information page." + + val script = rootProject.file("scripts/fetch_contributors.py") + + inputs.file(script).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.file(layout.projectDirectory.file("src/main/assets/contributors.json")) + outputs.dir(layout.projectDirectory.dir("src/main/assets/contributors")) + // Talks to the network, so its result is not reproducible from its inputs. + outputs.upToDateWhen { false } + + commandLine(resolvePythonExecutable(), script.absolutePath) +} + chaquopy { defaultConfig { version = "3.12" diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java index 95e4992f..7424b928 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java @@ -3,16 +3,19 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.util.Collections; -import java.util.List; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; @@ -23,6 +26,33 @@ public class AppCryptographyUtilTest { public static final String TEST_KEYSTORE_ALIAS = "__testing_alias__"; + /** Matches AppCryptographyUtil.ANDROID_KEYSTORE, which is private to that class. */ + private static final String ANDROID_KEYSTORE = "AndroidKeyStore"; + + /** + * Keys in the AndroidKeyStore outlive the process, the test run and app upgrades - only + * uninstalling clears them. Cleaning up before as well as after means a crashed or killed + * earlier run cannot leave behind a key that this one would silently reuse. + */ + @Before + public void removeTestKeyBeforeTest() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + deleteTestKey(); + } + + @After + public void removeTestKeyAfterTest() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + deleteTestKey(); + } + + private static void deleteTestKey() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + + if (keyStore.containsAlias(TEST_KEYSTORE_ALIAS)) { + keyStore.deleteEntry(TEST_KEYSTORE_ALIAS); + } + } + @Test public void testEncryptDecrypt() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { var instance = new AppCryptographyUtil(); @@ -38,8 +68,14 @@ public void testEncryptDecrypt() throws CertificateException, KeyStoreException, assertEquals(TEST_JSON, new String(decrypted, StandardCharsets.UTF_8)); + // Only that our own alias is there. The AndroidKeyStore is shared by everything + // running under the app's UID, so asserting on the whole list made this test pass on a + // clean device and fail on any device that had ever signed in - the login flow leaves + // AppKeyStoreConstants.KEYSTORE_ALIAS_ACCOUNT behind. var aliases = Collections.list(instance.getAliases()); - assertEquals(List.of(TEST_KEYSTORE_ALIAS), aliases); + assertTrue( + "Expected the keystore to contain " + TEST_KEYSTORE_ALIAS + ", got " + aliases, + aliases.contains(TEST_KEYSTORE_ALIAS)); byte[] flattened = result.flatten(); diff --git a/build.gradle.kts b/build.gradle.kts index 967c0417..2d996a71 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,7 @@ plugins { // --------------------------------------------------------------------------- // Aggregate test tasks // -// Tests live in four places across three runners (see TESTING.md). These wrap them +// Tests live in five places across three runners (see CONTRIBUTING.md). These wrap them // so there is one command to run everything, rather than four to remember. // --------------------------------------------------------------------------- @@ -120,14 +120,17 @@ val testAll by tasks.registering { doLast { logger.lifecycle("") logger.lifecycle("All device-free suites passed.") - logger.lifecycle("Instrumented tests were NOT run - use ./gradlew testAllOnDevice with an emulator booted.") + logger.lifecycle("Instrumented tests were NOT run - use ./gradlew testAllOnDevice.") } } val testAllOnDevice by tasks.registering { group = "verification" - description = "Runs every test suite, including instrumented tests (needs a device or emulator)." - dependsOn(testAll, ":app:connectedDebugAndroidTest") + description = "Runs every test suite, including instrumented tests on a self-provisioned emulator." + // The managed device rather than connectedDebugAndroidTest: Gradle creates and destroys + // the emulator per run, so nothing needs to be booted first and there is no reused ADB + // bridge to go stale. See the testOptions block in app/build.gradle.kts. + dependsOn(testAll, ":app:testEmulatorDebugAndroidTest") doLast { logger.lifecycle("") diff --git a/scripts/run_instrumented_tests.sh b/scripts/run_instrumented_tests.sh new file mode 100644 index 00000000..97d14e6a --- /dev/null +++ b/scripts/run_instrumented_tests.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# +# Run the instrumented tests on an emulator, and recover from a wedged one. +# +# The Android Gradle Plugin talks to devices over a long-lived ADB bridge that the Gradle +# daemon holds across invocations. When the emulator's adb daemon dies or goes stale, that +# handle is not reconnected: the next run fails to install, or hangs outright, with an error +# that has nothing to do with the code under test. Restarting the emulator fixes it, but only +# if the Gradle daemon is also stopped - otherwise it hands out the same dead bridge again. +# That is the whole reason this script exists. +# +# It pins ANDROID_SERIAL to an emulator so a run can never install to a physical phone. +# +# Usage: +# scripts/run_instrumented_tests.sh +# AVD=Pixel_9_Pro_API_36 scripts/run_instrumented_tests.sh +# GRADLE_TASK=:app:connectedDebugAndroidTest ATTEMPTS=3 scripts/run_instrumented_tests.sh +# +# Environment overrides: +# AVD AVD to launch if no emulator is running (default: first one listed) +# SERIAL Emulator serial to pin to (default: emulator-5554) +# ATTEMPTS How many times to try before giving up (default: 2) +# RUN_TIMEOUT Seconds before a run counts as hung (default: 900) +# BOOT_TIMEOUT Seconds to wait for the emulator to boot (default: 300) +# GRADLE_TASK Task to run (default: :app:connectedDebugAndroidTest) +# +set -uo pipefail + +AVD="${AVD:-}" +SERIAL="${SERIAL:-emulator-5554}" +ATTEMPTS="${ATTEMPTS:-2}" +RUN_TIMEOUT="${RUN_TIMEOUT:-900}" +BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}" +GRADLE_TASK="${GRADLE_TASK:-:app:connectedDebugAndroidTest}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +log() { printf '\n==> %s\n' "$*"; } +warn() { printf '\n!!! %s\n' "$*" >&2; } + +# -------------------------------------------------------------------------------------- +# Locate the SDK and the JDK. +# -------------------------------------------------------------------------------------- + +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$SDK" ]]; then + if [[ -n "${LOCALAPPDATA:-}" && -d "$LOCALAPPDATA/Android/Sdk" ]]; then + SDK="$LOCALAPPDATA/Android/Sdk" + elif [[ -d "$HOME/Android/Sdk" ]]; then + SDK="$HOME/Android/Sdk" + elif [[ -d "$HOME/Library/Android/sdk" ]]; then + SDK="$HOME/Library/Android/sdk" + fi +fi + +if [[ -z "$SDK" || ! -d "$SDK" ]]; then + warn "Could not find the Android SDK. Set ANDROID_HOME and try again." + exit 2 +fi + +# .exe on Windows, bare everywhere else. +pick_tool() { + local base="$1" + if [[ -x "$base.exe" ]]; then printf '%s' "$base.exe" + elif [[ -x "$base" ]]; then printf '%s' "$base" + else printf '' + fi +} + +ADB="$(pick_tool "$SDK/platform-tools/adb")" +EMULATOR="$(pick_tool "$SDK/emulator/emulator")" + +if [[ -z "$ADB" ]]; then + warn "No adb under $SDK/platform-tools." + exit 2 +fi + +# A JAVA_HOME pointing at anything below 17 fails deep inside the Android Gradle Plugin with +# a message that reads like a plugin bug, so fall back to the JDK that ships with Studio. +if [[ -z "${JAVA_HOME:-}" ]] || ! "${JAVA_HOME}/bin/java" -version 2>&1 | grep -qE '"(1[7-9]|2[0-9])'; then + for candidate in \ + "/c/Program Files/Android/Android Studio/jbr" \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "$HOME/.jdks/jbr-17" + do + if [[ -x "$candidate/bin/java" ]] || [[ -x "$candidate/bin/java.exe" ]]; then + export JAVA_HOME="$candidate" + log "Using JAVA_HOME=$JAVA_HOME" + break + fi + done +fi + +if [[ -x "./gradlew.bat" ]]; then GRADLEW="./gradlew.bat"; else GRADLEW="./gradlew"; fi + +# -------------------------------------------------------------------------------------- +# Emulator lifecycle. +# -------------------------------------------------------------------------------------- + +emulator_is_booted() { + local booted + booted="$(timeout 15 "$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n')" + [[ "$booted" == "1" ]] +} + +wait_for_boot() { + local waited=0 + log "Waiting for $SERIAL to finish booting (up to ${BOOT_TIMEOUT}s)" + while (( waited < BOOT_TIMEOUT )); do + if emulator_is_booted; then + log "$SERIAL is up" + return 0 + fi + sleep 5 + waited=$((waited + 5)) + done + warn "$SERIAL did not boot within ${BOOT_TIMEOUT}s" + return 1 +} + +start_emulator() { + if [[ -z "$EMULATOR" ]]; then + warn "No emulator binary under $SDK/emulator; start the AVD from Android Studio instead." + return 1 + fi + + local avd="$AVD" + if [[ -z "$avd" ]]; then + avd="$("$EMULATOR" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')" + fi + if [[ -z "$avd" ]]; then + warn "No AVDs found. Create one in Android Studio, or set AVD=." + return 1 + fi + + log "Starting AVD '$avd'" + # -no-snapshot-load matters: a snapshot can restore the same broken adb daemon state we + # are trying to escape, which makes the restart pointless. + "$EMULATOR" -avd "$avd" -no-snapshot-load -no-boot-anim >/dev/null 2>&1 & + disown || true + + wait_for_boot +} + +stop_emulator() { + log "Stopping $SERIAL" + timeout 30 "$ADB" -s "$SERIAL" emu kill >/dev/null 2>&1 + + # emu kill is a request, not a guarantee - an emulator that is already hung ignores it. + sleep 5 + if command -v taskkill >/dev/null 2>&1; then + taskkill //F //IM qemu-system-x86_64.exe >/dev/null 2>&1 || true + taskkill //F //IM qemu-system-aarch64.exe >/dev/null 2>&1 || true + else + pkill -f "qemu-system" >/dev/null 2>&1 || true + fi + sleep 5 +} + +reset_toolchain() { + # Both halves are needed. Restarting only the emulator leaves the Gradle daemon holding a + # dead ADB bridge, which is exactly the state that produces "Could not create ADB Bridge" + # and installs that hang forever. + log "Stopping the Gradle daemon and restarting the adb server" + "$GRADLEW" --stop >/dev/null 2>&1 || true + timeout 30 "$ADB" kill-server >/dev/null 2>&1 || true + timeout 30 "$ADB" start-server >/dev/null 2>&1 || true +} + +ensure_emulator() { + if timeout 20 "$ADB" devices 2>/dev/null | grep -q "^${SERIAL}[[:space:]]*device$" && emulator_is_booted; then + return 0 + fi + log "$SERIAL is not ready" + start_emulator +} + +# -------------------------------------------------------------------------------------- +# Run. +# -------------------------------------------------------------------------------------- + +# Pinned so a run can never install onto a physical device that happens to be plugged in. +export ANDROID_SERIAL="$SERIAL" + +run_tests() { + log "Running $GRADLE_TASK on $SERIAL (timeout ${RUN_TIMEOUT}s)" + timeout --foreground "$RUN_TIMEOUT" "$GRADLEW" "$GRADLE_TASK" + return $? +} + +overall=1 +for (( attempt = 1; attempt <= ATTEMPTS; attempt++ )); do + log "Attempt $attempt of $ATTEMPTS" + + if ! ensure_emulator; then + warn "Could not get an emulator ready" + if (( attempt < ATTEMPTS )); then + stop_emulator + reset_toolchain + continue + fi + break + fi + + run_tests + status=$? + + if (( status == 0 )); then + log "Instrumented tests passed" + overall=0 + break + fi + + if (( status == 124 )); then + warn "Run exceeded ${RUN_TIMEOUT}s and was killed - treating the emulator as wedged" + else + warn "Run failed with exit code $status" + fi + + # A genuine test failure is worth reporting as-is; a wedged toolchain is worth retrying. + # We cannot reliably tell them apart from the exit code alone, so retry either way and let + # the second run's report be the answer. A real failure simply fails twice. + if (( attempt < ATTEMPTS )); then + warn "Restarting the emulator and trying again" + stop_emulator + reset_toolchain + fi +done + +if (( overall != 0 )); then + warn "Instrumented tests did not pass after $ATTEMPTS attempt(s)" + warn "Report: app/build/reports/androidTests/connected/debug/index.html" +fi + +exit "$overall" From 34f3937467db6003e7b4cca7d56df0ccb7af3603 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 9 Aug 2026 12:42:55 +0200 Subject: [PATCH 29/50] Credit contributors on the Information page Replaces a hardcoded byline and the Developer Website button with a grid of contributor avatars, each linking to their GitHub profile. It picks up new contributors on its own, which is the point - a list nobody has to remember to update is the only kind that stays correct. Generated at build time by scripts/fetch_contributors.py into assets, and refreshed weekly by a workflow that opens a PR when it changes. The app reads the assets and never calls GitHub, which matters: the unauthenticated API allows 60 requests an hour per IP and carrier-grade NAT puts a great many users behind each one, so a runtime call would be rate limited in practice. A token cannot be shipped to raise that, since anything in the APK can be read back out. Bundling also avoids handing every user's IP to GitHub when they open a screen with no other remote content - this app's users are avoiding one company's location network, and quietly signing them up to another's CDN is a poor trade. Ordered by recency-weighted contribution volume rather than commit count: each week's additions and deletions are log-compressed, so one vendored dependency cannot outrank a year of real work, then decayed with a 52-week half-life. It is a heuristic and the script says so - churn is not value, and a well-judged ten-line fix scores badly. The grid uses a small FlowLayout that wraps on measured width rather than a fixed column count, so it fills the space it has and only scrolls once its rows no longer fit. Bots are filtered out; stale avatars are removed. Also restructures the page from a bottom-pinned block overlapping a full-height one into a single column, which is what lets the grid take the leftover space without pushing the buttons off screen. Co-Authored-By: Claude Opus 5 --- .github/workflows/update-contributors.yml | 52 +++++ app/src/main/assets/contributors.json | 34 +++ .../main/assets/contributors/arbespalov.png | Bin 0 -> 4362 bytes app/src/main/assets/contributors/fixxy.png | Bin 0 -> 3257 bytes app/src/main/assets/contributors/flole998.png | Bin 0 -> 1539 bytes .../main/assets/contributors/horihori2013.png | Bin 0 -> 1566 bytes .../main/assets/contributors/parawanderer.png | Bin 0 -> 3268 bytes .../opentagviewer/InformationActivity.java | 114 +++++++++- .../opentagviewer/ui/widget/FlowLayout.java | 155 +++++++++++++ .../main/res/layout/activity_information.xml | 67 +++--- .../main/res/layout/contributor_avatar.xml | 17 ++ app/src/main/res/values/themes.xml | 7 + scripts/fetch_contributors.py | 207 ++++++++++++++++++ 13 files changed, 606 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/update-contributors.yml create mode 100644 app/src/main/assets/contributors.json create mode 100644 app/src/main/assets/contributors/arbespalov.png create mode 100644 app/src/main/assets/contributors/fixxy.png create mode 100644 app/src/main/assets/contributors/flole998.png create mode 100644 app/src/main/assets/contributors/horihori2013.png create mode 100644 app/src/main/assets/contributors/parawanderer.png create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java create mode 100644 app/src/main/res/layout/contributor_avatar.xml create mode 100644 scripts/fetch_contributors.py diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 00000000..3449c69e --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,52 @@ +name: Update contributors + +# Keeps app/src/main/assets/contributors.json and the bundled avatars current, so the +# Information page credits everyone without anyone having to remember to update a list. +# +# It runs here rather than during the app build on purpose: the output is committed source, +# and regenerating it on every build would dirty the working tree whenever someone changed +# their GitHub avatar, and would make offline builds fail. +# +# GITHUB_TOKEN gives 1000 requests/hour, so the 60/hour unauthenticated limit is irrelevant. + +on: + schedule: + # Weekly. Contributor lists do not move fast enough to warrant more. + - cron: '0 4 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Regenerate the contributor list + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/fetch_contributors.py + + - name: Open a PR if anything changed + uses: peter-evans/create-pull-request@v6 + with: + commit-message: Update the bundled contributor list + title: Update the bundled contributor list + body: | + Regenerated by `scripts/fetch_contributors.py`. + + Ordering is recency-weighted contribution volume, so it moves as people + come and go. Avatars are refreshed at the same time. + branch: chore/update-contributors + delete-branch: true + add-paths: | + app/src/main/assets/contributors.json + app/src/main/assets/contributors/ diff --git a/app/src/main/assets/contributors.json b/app/src/main/assets/contributors.json new file mode 100644 index 00000000..fc171fab --- /dev/null +++ b/app/src/main/assets/contributors.json @@ -0,0 +1,34 @@ +{ + "contributors": [ + { + "login": "parawanderer", + "profileUrl": "https://github.com/parawanderer", + "avatar": "parawanderer.png", + "commits": 165 + }, + { + "login": "horihori2013", + "profileUrl": "https://github.com/horihori2013", + "avatar": "horihori2013.png", + "commits": 1 + }, + { + "login": "Flole998", + "profileUrl": "https://github.com/Flole998", + "avatar": "flole998.png", + "commits": 1 + }, + { + "login": "arBespalov", + "profileUrl": "https://github.com/arBespalov", + "avatar": "arbespalov.png", + "commits": 1 + }, + { + "login": "Fixxy", + "profileUrl": "https://github.com/Fixxy", + "avatar": "fixxy.png", + "commits": 1 + } + ] +} diff --git a/app/src/main/assets/contributors/arbespalov.png b/app/src/main/assets/contributors/arbespalov.png new file mode 100644 index 0000000000000000000000000000000000000000..71b8a3ac488f1bc3112acdae3d0f5753383ee88a GIT binary patch literal 4362 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 literal 0 HcmV?d00001 diff --git a/app/src/main/assets/contributors/fixxy.png b/app/src/main/assets/contributors/fixxy.png new file mode 100644 index 0000000000000000000000000000000000000000..68d6016c61ca421719d6d520fd24e6e406ba3310 GIT binary patch literal 3257 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 literal 0 HcmV?d00001 diff --git a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java index 30d0992b..c3a6536f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java @@ -2,26 +2,42 @@ import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.util.Log; +import android.view.LayoutInflater; import android.view.View; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.TooltipCompat; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.databinding.DataBindingUtil; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.android.material.imageview.ShapeableImageView; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; import dev.wander.android.opentagviewer.databinding.ActivityInformationBinding; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.widget.FlowLayout; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; public class InformationActivity extends AppCompatActivity { private static final String TAG = InformationActivity.class.getSimpleName(); + /** Both generated by scripts/fetch_contributors.py; see the workflow that runs it. */ + private static final String CONTRIBUTORS_ASSET = "contributors.json"; + private static final String AVATAR_ASSET_DIR = "contributors"; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -47,6 +63,8 @@ protected void onCreate(Bundle savedInstanceState) { throw new RuntimeException(e); } + this.showContributors(); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); @@ -54,22 +72,104 @@ protected void onCreate(Bundle savedInstanceState) { }); } + /** + * Fills the avatar grid from the list bundled at build time. + *
+ * Read from assets rather than fetched, which is what + * {@code scripts/fetch_contributors.py} exists for. Calling the GitHub API here would be + * rate limited in practice - it allows 60 requests an hour per IP, and carrier-grade NAT + * puts a great many users behind each one - and it would hand every user's IP to GitHub + * on opening a screen that shows no remote content otherwise. + */ + private void showContributors() { + FlowLayout grid = findViewById(R.id.contributors_flow); + if (grid == null) { + return; + } + final List contributors = readContributors(); + if (contributors.isEmpty()) { + // Nothing to show, so give the space back to the rest of the page. + findViewById(R.id.contributors_scroll).setVisibility(View.GONE); + return; + } - public void onClickDeveloperWebsite(View view) { - Log.d(TAG, "Clicked Developer Website button"); + final LayoutInflater inflater = getLayoutInflater(); - var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); - assert properties != null; - final String developerWebsite = properties.getProperty("developerWebsite"); + for (Contributor contributor : contributors) { + ShapeableImageView avatar = (ShapeableImageView) + inflater.inflate(R.layout.contributor_avatar, grid, false); - Uri devSite = Uri.parse(developerWebsite); - Intent intent = new Intent(Intent.ACTION_VIEW, devSite); + Bitmap bitmap = readAvatar(contributor.avatar); + if (bitmap == null) { + // The manifest and the images are generated together, so this only happens if + // one of them was edited by hand. Skip rather than show an empty circle. + Log.w(TAG, "No bundled avatar for " + contributor.login + "; skipping"); + continue; + } + avatar.setImageBitmap(bitmap); + avatar.setContentDescription(contributor.login); + + // Long press rather than a tooltip attribute: works below API 26 too. + TooltipCompat.setTooltipText(avatar, contributor.login); + + avatar.setOnClickListener(v -> openUrl(contributor.profileUrl)); + + grid.addView(avatar); + } + } + + private List readContributors() { + try (InputStream in = getAssets().open(CONTRIBUTORS_ASSET)) { + ContributorsFile parsed = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readValue(in, ContributorsFile.class); + + return parsed.contributors == null ? List.of() : parsed.contributors; + } catch (IOException e) { + Log.w(TAG, "Could not read " + CONTRIBUTORS_ASSET, e); + return List.of(); + } + } + + private Bitmap readAvatar(final String filename) { + if (filename == null || filename.isBlank()) { + return null; + } + + try (InputStream in = getAssets().open(AVATAR_ASSET_DIR + "/" + filename)) { + return BitmapFactory.decodeStream(in); + } catch (IOException e) { + Log.w(TAG, "Could not read avatar " + filename, e); + return null; + } + } + + private void openUrl(final String url) { + if (url == null || url.isBlank()) { + return; + } + + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); if (intent.resolveActivity(getPackageManager()) != null) { this.startActivity(intent); } } + /** Shape of assets/contributors.json. */ + public static class ContributorsFile { + public List contributors; + } + + public static class Contributor { + public String login; + public String profileUrl; + public String avatar; + public int commits; + } + + + public void onClickWiki(View view) { Log.d(TAG, "Clicked App Wiki button"); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java new file mode 100644 index 00000000..1449a70d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java @@ -0,0 +1,155 @@ +package dev.wander.android.opentagviewer.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lays children out left to right, wrapping to a new row when the current one runs out of + * width, and reports the height that actually took. + *
+ * A GridLayout with a fixed column count would misuse the space on anything that is not the + * screen it was tuned for - too cramped on a tablet, overflowing on a small phone. Here the + * number of items per row falls out of the measured width instead. + *
+ * Rows are centred horizontally, which reads better than a ragged last row for a set of + * equally sized items such as avatars. + */ +public class FlowLayout extends ViewGroup { + + private final List> rows = new ArrayList<>(); + private final List rowHeights = new ArrayList<>(); + private final List rowWidths = new ArrayList<>(); + + public FlowLayout(Context context) { + super(context); + } + + public FlowLayout(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public FlowLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new MarginLayoutParams(getContext(), attrs); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + } + + @Override + protected LayoutParams generateLayoutParams(LayoutParams p) { + return new MarginLayoutParams(p); + } + + @Override + protected boolean checkLayoutParams(LayoutParams p) { + return p instanceof MarginLayoutParams; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + this.rows.clear(); + this.rowHeights.clear(); + this.rowWidths.clear(); + + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) + - getPaddingLeft() - getPaddingRight(); + + // UNSPECIFIED means nothing is constraining us, so wrapping would never trigger and + // every child would end up on one row. Treat it as a single row on purpose. + final boolean canWrap = widthMode != MeasureSpec.UNSPECIFIED; + + List row = new ArrayList<>(); + int rowWidth = 0; + int rowHeight = 0; + int totalHeight = 0; + int widestRow = 0; + + for (int i = 0; i < getChildCount(); i++) { + final View child = getChildAt(i); + if (child.getVisibility() == GONE) { + continue; + } + + measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0); + + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + final int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin; + final int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin; + + if (canWrap && !row.isEmpty() && rowWidth + childWidth > maxWidth) { + this.rows.add(row); + this.rowHeights.add(rowHeight); + this.rowWidths.add(rowWidth); + totalHeight += rowHeight; + widestRow = Math.max(widestRow, rowWidth); + + row = new ArrayList<>(); + rowWidth = 0; + rowHeight = 0; + } + + row.add(child); + rowWidth += childWidth; + rowHeight = Math.max(rowHeight, childHeight); + } + + if (!row.isEmpty()) { + this.rows.add(row); + this.rowHeights.add(rowHeight); + this.rowWidths.add(rowWidth); + totalHeight += rowHeight; + widestRow = Math.max(widestRow, rowWidth); + } + + final int measuredWidth = widthMode == MeasureSpec.EXACTLY + ? MeasureSpec.getSize(widthMeasureSpec) + : widestRow + getPaddingLeft() + getPaddingRight(); + + setMeasuredDimension( + resolveSize(measuredWidth, widthMeasureSpec), + resolveSize(totalHeight + getPaddingTop() + getPaddingBottom(), heightMeasureSpec)); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int contentWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); + int y = getPaddingTop(); + + for (int rowIndex = 0; rowIndex < this.rows.size(); rowIndex++) { + final List row = this.rows.get(rowIndex); + int x = getPaddingLeft() + ((contentWidth - this.rowWidths.get(rowIndex)) / 2); + + for (View child : row) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + + final int childLeft = x + lp.leftMargin; + final int childTop = y + lp.topMargin; + + child.layout( + childLeft, + childTop, + childLeft + child.getMeasuredWidth(), + childTop + child.getMeasuredHeight()); + + x += child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin; + } + + y += this.rowHeights.get(rowIndex); + } + } +} diff --git a/app/src/main/res/layout/activity_information.xml b/app/src/main/res/layout/activity_information.xml index 98f0ad07..22a001e3 100644 --- a/app/src/main/res/layout/activity_information.xml +++ b/app/src/main/res/layout/activity_information.xml @@ -17,12 +17,14 @@ android:background="@color/md_theme_background" tools:context=".InformationActivity"> + + android:orientation="vertical"> -
- + + + + + + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="40dp">