Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ios-native/Core/Maps/PlaceDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 79 additions & 0 deletions ios-native/PetNoteAppTests/AppleMapsDeviceProbeTests.swift
Original file line number Diff line number Diff line change
@@ -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<String>] = [:]
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
}
}
11 changes: 8 additions & 3 deletions ios-native/docs/apple-maps-places-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 上搜地点会搜不到苹果的结果(没有编号的结果本来就不显示),不会崩。

## 以后存什么

Expand Down Expand Up @@ -110,14 +115,14 @@
| 9 | iOS:编辑聚会 | #260 | — |
| — | 把这些函数部署到测试项目 | | 批准(原来清单 B 的一部分) |
| — | 部署到生产 | | 批准 |
| — | 真机验证编号和限流 | | 下次装机时连手机 |
| — | 真机验证编号和限流 | 见「已经验证过的」 | — |
| — | 隐私政策加一句「iOS 上的地点搜索会发给苹果」 | | 法律文字由你定 |

- 1a 到 9 全部在本机模拟后端上测。1a、1b 和苹果地图无关,先做。
- 服务器的改动部署之后,App 才能在测试云和生产上用这些新功能,所以部署要排在 App 发版之前。

## 风险

- **限流**:苹果保留限制查询次数的权利(条款 2.7),具体限额没公开。地点列表一页 20 个,我的打卡最多 100 个。Mac 上一次查 26 个没问题,真机上再确认。
- **限流**:苹果保留限制查询次数的权利(条款 2.7),具体限额没公开。地点列表一页 20 个,我的打卡最多 100 个。一次查 26 个,Mac 上和真机上都没问题。
- **店改名**:显示的总是苹果最新的名字。这是好处,但同一个地点前后看到的名字可能不一样。
- **编号失效**:店关门、编号失效时,那个地点会显示「找不到这个地点」,评价和打卡都还在。
Loading