diff --git a/ios-native/Core/Maps/PlaceDirectory.swift b/ios-native/Core/Maps/PlaceDirectory.swift index 2cc2340..e8fac68 100644 --- a/ios-native/Core/Maps/PlaceDirectory.swift +++ b/ios-native/Core/Maps/PlaceDirectory.swift @@ -47,7 +47,8 @@ protocol PlaceDirectory: Sendable { /// the same place at the same moment share one request. Apple may limit how /// many requests an app makes (Attachment 6, 2.7) without saying how many: a /// list of twenty places asked at once was fine on 2026-09-30, measured on a -/// Mac, with the slowest answer in 0.26s. +/// Mac, with the slowest answer in 0.26s; twenty-six on an iPhone on +/// 2026-10-01 took 0.26s all told (`AppleMapsDeviceProbeTests`). /// /// "Apple no longer knows it" is remembered too, so a list does not ask again /// for a place that is gone. A failure is not: the next look tries again. diff --git a/ios-native/PetNoteAppTests/AppleMapsDeviceProbeTests.swift b/ios-native/PetNoteAppTests/AppleMapsDeviceProbeTests.swift new file mode 100644 index 0000000..92e31dc --- /dev/null +++ b/ios-native/PetNoteAppTests/AppleMapsDeviceProbeTests.swift @@ -0,0 +1,79 @@ +import Foundation +import MapKit +import Testing +@testable import PetNote + +/// On a real iPhone, the two things the Mac run could not settle +/// (`docs/apple-maps-places-plan.md`): that Apple Maps gives a search's places +/// an identifier there too — a developer reported none on iOS 18 devices — +/// and that a page's worth of them can be looked up at once without being +/// throttled. Through the app's own MapKit calls, in the app's own process. +/// Only counts and times are printed; nothing Apple says about a place is +/// kept. +@Suite(.enabled(if: RealIPhone.isThisOne, "on a real iPhone only"), .serialized) +struct AppleMapsDeviceProbeTests { + /// Ordinary searches, so the answers are ordinary public places. + private static let queries = ["dog park Boston", "pet store Boston", "park Cambridge MA", "cafe Somerville MA"] + + @Test func searchesGiveTheirPlacesIdentifiersAndTheSameOnesAgain() async throws { + var withIdentifier = 0 + var without = 0 + var first: [String: Set] = [:] + for query in Self.queries { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = query + request.resultTypes = .pointOfInterest + let items = try await MKLocalSearch(request: request).start().mapItems + withIdentifier += items.filter { $0.identifier != nil }.count + without += items.filter { $0.identifier == nil }.count + first[query] = Set(items.compactMap { $0.identifier?.rawValue }) + } + print("MEASURED apple search: \(withIdentifier) places with an identifier, \(without) without, in \(Self.queries.count) searches") + #expect(withIdentifier > 0, "no identifiers on this device") + + var again = 0 + var same = 0 + for query in Self.queries { + let ids = try await MapKitPlaceDirectory.Lookups.mapKit.search(query).map(\.applePlaceID) + again += ids.count + same += ids.filter { first[query]?.contains($0) == true }.count + } + print("MEASURED apple search again: \(same) of \(again) identifiers the same as the first time") + #expect(again > 0) + #expect(Double(same) >= Double(again) * 0.8, "the same places came back under other identifiers") + } + + @Test func aPageOfPlacesIsLookedUpAtOnce() async throws { + var ids: [String] = [] + for query in Self.queries where ids.count < 26 { + for id in try await MapKitPlaceDirectory.Lookups.mapKit.search(query).map(\.applePlaceID) where !ids.contains(id) { + ids.append(id) + } + } + ids = Array(ids.prefix(26)) + let started = Date() + let answered = try await withThrowingTaskGroup(of: Bool.self) { group in + for id in ids { + group.addTask { try await MapKitPlaceDirectory.Lookups.mapKit.place(id) != nil } + } + var found = 0 + for try await isFound in group where isFound { found += 1 } + return found + } + let seconds = Date().timeIntervalSince(started) + print(String(format: "MEASURED apple lookups: %d of %d answered at once in %.2f s", answered, ids.count, seconds)) + #expect(ids.count >= 20, "too few places found to make a page") + #expect(answered == ids.count, "not every place was answered: throttled, or gone") + } +} + +/// Its own type: the suite's condition cannot be on the suite itself. +private enum RealIPhone { + static var isThisOne: Bool { + #if targetEnvironment(simulator) + false + #else + true + #endif + } +} diff --git a/ios-native/docs/apple-maps-places-plan.md b/ios-native/docs/apple-maps-places-plan.md index a374729..3d5f51e 100644 --- a/ios-native/docs/apple-maps-places-plan.md +++ b/ios-native/docs/apple-maps-places-plan.md @@ -24,7 +24,12 @@ 09-30 在这台 Mac 上用 MapKit 试过,结果只临时用于验证,没有保存: - 搜 30 个地点,26 个有编号。没有编号的是纯门牌地址(比如「360 Huntington Ave」)和一片区域(比如哈佛广场、唐人街)。公园、店铺、宠物医院都有编号。 - 凭编号把 26 个地点一起查回来,总共 0.26 秒,中位数 0.13 秒,没有被限流。再查一遍是 0.08 秒。 -- **还要在真机上确认**:有开发者报告过 iOS 18 真机上拿不到编号。下次装机时一起验。 + +10-01 在真机上(iPhone 17 Pro,iOS 27.0.1)用 App 自己的 MapKit 调用又试了一遍(`PetNoteAppTests/AppleMapsDeviceProbeTests.swift`,只打印个数和时间,不保存苹果的内容): +- 4 次搜索(「dog park Boston」「pet store Boston」「park Cambridge MA」「cafe Somerville MA」)共 100 个地点,100 个都有编号。 +- 同样 4 次再搜一遍,100 个编号和第一次全部相同。 +- 凭编号把 26 个地点同时查回来,26 个都有回答,总共 0.26 秒,没有被限流。 +- 有开发者报告过 iOS 18 真机上拿不到编号。这台手机是 iOS 27,iOS 18 真机仍没验过,手边没有。真拿不到的话,iOS 18 上搜地点会搜不到苹果的结果(没有编号的结果本来就不显示),不会崩。 ## 以后存什么 @@ -110,7 +115,7 @@ | 9 | iOS:编辑聚会 | #260 | — | | — | 把这些函数部署到测试项目 | | 批准(原来清单 B 的一部分) | | — | 部署到生产 | | 批准 | -| — | 真机验证编号和限流 | | 下次装机时连手机 | +| — | 真机验证编号和限流 | 见「已经验证过的」 | — | | — | 隐私政策加一句「iOS 上的地点搜索会发给苹果」 | | 法律文字由你定 | - 1a 到 9 全部在本机模拟后端上测。1a、1b 和苹果地图无关,先做。 @@ -118,6 +123,6 @@ ## 风险 -- **限流**:苹果保留限制查询次数的权利(条款 2.7),具体限额没公开。地点列表一页 20 个,我的打卡最多 100 个。Mac 上一次查 26 个没问题,真机上再确认。 +- **限流**:苹果保留限制查询次数的权利(条款 2.7),具体限额没公开。地点列表一页 20 个,我的打卡最多 100 个。一次查 26 个,Mac 上和真机上都没问题。 - **店改名**:显示的总是苹果最新的名字。这是好处,但同一个地点前后看到的名字可能不一样。 - **编号失效**:店关门、编号失效时,那个地点会显示「找不到这个地点」,评价和打卡都还在。