fix: use country-level resolution for low-precision geohashes (#1044)

* fix: use country-level resolution for low-precision geohashes (#887)

* fix: skip admin fallback when country exists but is duplicate

Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.

* fix: migrate stale low-precision bookmark names so country-first logic applies

Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.

* Fix low-precision geohash country names

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
chernomortsev evgeny
2026-07-10 15:16:02 +02:00
committed by GitHub
co-authored by jack jack
parent 820c933958
commit 733098bb63
2 changed files with 124 additions and 13 deletions
+37 -11
View File
@@ -109,6 +109,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private let teleportedStoreKey = "locationChannel.teleportedSet" private let teleportedStoreKey = "locationChannel.teleportedSet"
private let bookmarksKey = "locationChannel.bookmarks" private let bookmarksKey = "locationChannel.bookmarks"
private let bookmarkNamesKey = "locationChannel.bookmarkNames" private let bookmarkNamesKey = "locationChannel.bookmarkNames"
private let bookmarkNamesSchemaVersionKey = "locationChannel.bookmarkNamesSchemaVersion"
private let bookmarkNamesCurrentSchemaVersion = 1
// MARK: - Published State (Channel) // MARK: - Published State (Channel)
@@ -222,6 +224,26 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
let dict = try? JSONDecoder().decode([String: String].self, from: data) { let dict = try? JSONDecoder().decode([String: String].self, from: data) {
bookmarkNames = dict bookmarkNames = dict
} }
migrateBookmarkNamesIfNeeded()
}
/// Drops names cached before low-precision geohashes became country-first.
/// Correctly resolved names written after this migration must survive
/// subsequent launches, so the migration is explicitly versioned.
private func migrateBookmarkNamesIfNeeded() {
guard storage.integer(forKey: bookmarkNamesSchemaVersionKey) < bookmarkNamesCurrentSchemaVersion else {
return
}
let retainedNames = bookmarkNames.filter {
Self.normalizeGeohash($0.key).count > 2
}
if retainedNames.count != bookmarkNames.count {
bookmarkNames = retainedNames
persistBookmarkNames()
}
storage.set(bookmarkNamesCurrentSchemaVersion, forKey: bookmarkNamesSchemaVersionKey)
} }
private func initializePermissionState() { private func initializePermissionState() {
@@ -525,15 +547,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins: [String] = [] var uniqueRegions: [String] = []
var seenAdmins = Set<String>() var seenRegions = Set<String>()
var idx = 0 var idx = 0
func step() { func step() {
if idx >= points.count { if idx >= points.count {
let finalName: String? = { let finalName: String? = {
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } if uniqueRegions.count >= 2 { return uniqueRegions[0] + " and " + uniqueRegions[1] }
return uniqueAdmins.first return uniqueRegions.first
}() }()
if let finalName = finalName, !finalName.isEmpty { if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -549,12 +571,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return } guard self != nil else { return }
if let pm = placemarks?.first { if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { if let country = pm.country, !country.isEmpty {
seenAdmins.insert(admin) if !seenRegions.contains(country) {
uniqueAdmins.append(admin) seenRegions.insert(country)
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { uniqueRegions.append(country)
seenAdmins.insert(country) }
uniqueAdmins.append(country) } else if let admin = pm.administrativeArea,
!admin.isEmpty,
!seenRegions.contains(admin) {
seenRegions.insert(admin)
uniqueRegions.append(admin)
} }
} }
step() step()
@@ -566,7 +592,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len { switch len {
case 0...2: case 0...2:
return pm.administrativeArea ?? pm.country return pm.country ?? pm.administrativeArea
case 3...4: case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5: case 5:
@@ -207,7 +207,54 @@ final class LocationStateManagerTests: XCTestCase {
XCTAssertFalse(reloaded.teleported) XCTAssertFalse(reloaded.teleported)
} }
func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async { func test_loadPersistedState_migratesLowPrecisionBookmarkNamesOnce() throws {
let storage = makeStorage()
let staleNames = [
"gc": "England",
"u3": "Île-de-France",
"u4pr": "Paris",
"u4pruy": "Le Marais"
]
storage.set(try JSONEncoder().encode(staleNames), forKey: "locationChannel.bookmarkNames")
let migrated = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: false
)
XCTAssertNil(migrated.bookmarkNames["gc"])
XCTAssertNil(migrated.bookmarkNames["u3"])
XCTAssertEqual(migrated.bookmarkNames["u4pr"], "Paris")
XCTAssertEqual(migrated.bookmarkNames["u4pruy"], "Le Marais")
XCTAssertEqual(storage.integer(forKey: "locationChannel.bookmarkNamesSchemaVersion"), 1)
let persistedData = try XCTUnwrap(storage.data(forKey: "locationChannel.bookmarkNames"))
let persistedNames = try JSONDecoder().decode([String: String].self, from: persistedData)
XCTAssertEqual(persistedNames, [
"u4pr": "Paris",
"u4pruy": "Le Marais"
])
let correctedNames = [
"gc": "United Kingdom",
"u4pr": "Paris"
]
storage.set(try JSONEncoder().encode(correctedNames), forKey: "locationChannel.bookmarkNames")
let reloaded = LocationStateManager(
storage: storage,
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: false
)
XCTAssertEqual(reloaded.bookmarkNames["gc"], "United Kingdom")
XCTAssertEqual(reloaded.bookmarkNames["u4pr"], "Paris")
}
func test_addBookmark_lowPrecisionPrefersCountryOverAdministrativeAreas() async {
let geocoder = MockLocationGeocoder() let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")]) geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
@@ -223,12 +270,50 @@ final class LocationStateManagerTests: XCTestCase {
manager.addBookmark("9q") manager.addBookmark("9q")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" } let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "United States" }
XCTAssertTrue(bookmarkResolved) XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 5) XCTAssertEqual(geocoder.reverseRequests.count, 5)
XCTAssertEqual(manager.bookmarks, ["9q"]) XCTAssertEqual(manager.bookmarks, ["9q"])
} }
func test_addBookmark_lowPrecisionFallsBackToDistinctAdministrativeAreas() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "California")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Arizona")])
geocoder.enqueue(placemarks: [makePlacemark(administrativeArea: "Nevada")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 5)
}
func test_addBookmark_higherPrecisionStillPrefersAdministrativeArea() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
let manager = LocationStateManager(
storage: makeStorage(),
locationManager: MockLocationManager(authorizationStatus: .denied),
geocoder: geocoder,
shouldInitializeCoreLocation: false
)
manager.addBookmark("9q8")
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q8"] == "California" }
XCTAssertTrue(bookmarkResolved)
XCTAssertEqual(geocoder.reverseRequests.count, 1)
}
private func makeStorage() -> UserDefaults { private func makeStorage() -> UserDefaults {
let suiteName = "LocationStateManagerTests-\(UUID().uuidString)" let suiteName = "LocationStateManagerTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)! let storage = UserDefaults(suiteName: suiteName)!