mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool The nearby-notes counter used to open a live building-precision (precision-8) geohash REQ to the closest geo relays whenever the mesh public timeline was visible — a passive location side-channel with no opt-in. Now nothing subscribes until one explicit act reveals the counter for the session: tapping the new "check for notes left here" line on the empty mesh timeline (static, no network), opening the notices sheet's geo tab, or a successful /drop. The app-info setting stays the default-ON kill switch and still gates /drop. Subscription hygiene alongside: - LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the main actor like the timer teardown) instead of only invalidating timers. - New refcounted LocationNotesPool dedupes the counter's and the notices sheet's identical 9-cell kind-1 REQs into one shared manager per geohash; both callers release-and-reacquire instead of retargeting in place, and the sheet releases its ref on dismissal. The reveal affordance is localized across all 29 catalog locales, and new NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool refcounting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only Fixes the verified review findings on the tap-to-reveal PR: - Permission dead-end: the "check for notes left here" hint now renders only when location permission is already authorized (it never prompts). Previously a location-denied install could tap it, flip the sticky revealed flag, and get nothing for the rest of the session because retarget() guards on authorization. Predicate lives on NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive to the published permission state. - Building-cell sampling: background geohash sampling subscribed geo-sample-<gh> for every regional level including the precision-8 building cell, pre-reveal — contradicting the claim that nothing building-precision hits a relay before the explicit act. GeoChannelCoordinator now excludes the building level until NearbyNotesCounter.revealed (injectable publisher for tests); coarser levels keep the nearby-conversation hint and participant counts working, and bookmarks stay exempt (bookmarking is explicit). - Implicit reveal: opening the notices sheet no longer reveals — the sheet auto-lands on the geo tab whenever a location channel is selected, so browsing a remote geohash and opening notices revealed the LOCAL building subscription. reveal() now fires only on the person actively picking the geo segment, and only when the sheet has a geo scope (the empty-mesh hint tap and /drop stay as before). - Subscription hygiene: switching the sheet geo → mesh releases the pooled notes manager (the REQ was left streaming behind the mesh board); switching back re-acquires from the pool. The dismissal release stays balanced — liveGeoManager is nil after the tab-switch release. - VoiceOver: the hint button exposes the plain localized action text instead of the decorated "* 📍 … *" label. - Removed LocationNotesManager.setGeohash: zero callers, and calling it on a pooled instance would corrupt the pool's keying and refcounts. New tests: hint permission gate, explicit-geo-tab reveal contract, building-cell sampling exclusion before/after reveal, pool release/re-acquire round trip. Full suite (1467) green; iOS simulator build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the snapshot -> allPeers binding crosses the mock transport's unstructured Task, UnifiedPeerService.updatePeers, a receive(on: main), and another Task { @MainActor } in bindPeerService — all contending with every parallel worker. On the failing runner the whole suite took 10.1s (usually ~4.4s locally), so the positive 5s defaultTimeout wait lost the race. That's exactly the case TestConstants.longTimeout documents; passing runs return as soon as the condition holds and never pay it. Not caused by the tap-to-reveal changes: nothing on that pipeline was touched, and 20 full parallel-suite loops each on the branch and on origin/main reproduce zero failures locally. The two sibling waits on the same pipeline in this file get the same timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
9.1 KiB
Swift
236 lines
9.1 KiB
Swift
//
|
|
// GeoChannelCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext` —
|
|
// proving the coordinator works without a `ChatViewModel`, following the
|
|
// `ChatDeliveryCoordinatorContextTests` exemplar.
|
|
//
|
|
// Scope note: the location/bookmark managers are real `LocationStateManager`
|
|
// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation
|
|
// seams. `TorManager` has no test seam (private init singleton); sampling
|
|
// tests pin `TorManager.shared` to foreground (its default) so the
|
|
// begin-sampling branch is deterministic.
|
|
//
|
|
|
|
import Testing
|
|
import Combine
|
|
import Foundation
|
|
import CoreLocation
|
|
import Tor
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `GeoChannelContext` proving that
|
|
/// `GeoChannelCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockGeoChannelContext: GeoChannelContext {
|
|
private(set) var switchedChannels: [ChannelID] = []
|
|
private(set) var beginSamplingCalls: [[String]] = []
|
|
private(set) var endSamplingCount = 0
|
|
|
|
func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) }
|
|
func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) }
|
|
func endGeohashSampling() { endSamplingCount += 1 }
|
|
}
|
|
|
|
// MARK: - CoreLocation Seams
|
|
|
|
private final class StubLocationManaging: LocationStateManaging {
|
|
weak var delegate: CLLocationManagerDelegate?
|
|
var desiredAccuracy: CLLocationAccuracy = 0
|
|
var distanceFilter: CLLocationDistance = 0
|
|
var authorizationStatus: CLAuthorizationStatus = .denied
|
|
|
|
func requestWhenInUseAuthorization() {}
|
|
func requestLocation() {}
|
|
func startUpdatingLocation() {}
|
|
func stopUpdatingLocation() {}
|
|
}
|
|
|
|
private final class StubLocationGeocoder: LocationStateGeocoding {
|
|
func cancelGeocode() {}
|
|
func reverseGeocodeLocation(
|
|
_ location: CLLocation,
|
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
|
) {
|
|
completionHandler(nil, nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager {
|
|
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
|
|
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
|
|
if storage == nil {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
return LocationStateManager(
|
|
storage: defaults,
|
|
locationManager: StubLocationManaging(),
|
|
geocoder: StubLocationGeocoder(),
|
|
shouldInitializeCoreLocation: false
|
|
)
|
|
}
|
|
|
|
/// Polls until `condition` holds, letting main-actor tasks and main-queue
|
|
/// Combine hops drain in between.
|
|
@MainActor
|
|
private func waitUntil(_ condition: () -> Bool) async -> Bool {
|
|
for _ in 0..<100 {
|
|
if condition() { return true }
|
|
await Task.yield()
|
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
}
|
|
return condition()
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no
|
|
/// `ChatViewModel`.
|
|
struct GeoChannelCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws {
|
|
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
|
|
let storage = UserDefaults(suiteName: suiteName)!
|
|
storage.removePersistentDomain(forName: suiteName)
|
|
let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
|
|
storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected")
|
|
|
|
let locationManager = makeLocationManager(storage: storage)
|
|
let context = MockGeoChannelContext()
|
|
let coordinator = GeoChannelCoordinator(
|
|
locationManager: locationManager,
|
|
bookmarksStore: locationManager,
|
|
torManager: TorManager.shared,
|
|
context: context
|
|
)
|
|
defer { withExtendedLifetime(coordinator) {} }
|
|
|
|
// The persisted selection is announced and, with no regional or
|
|
// bookmarked geohashes, sampling ends rather than begins.
|
|
#expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 })
|
|
#expect(context.switchedChannels.allSatisfy { $0 == persisted })
|
|
#expect(context.beginSamplingCalls.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func selectingChannel_propagatesSwitchToContext() async {
|
|
let locationManager = makeLocationManager()
|
|
let context = MockGeoChannelContext()
|
|
let coordinator = GeoChannelCoordinator(
|
|
locationManager: locationManager,
|
|
bookmarksStore: locationManager,
|
|
torManager: TorManager.shared,
|
|
context: context
|
|
)
|
|
defer { withExtendedLifetime(coordinator) {} }
|
|
|
|
#expect(await waitUntil { context.switchedChannels.contains(.mesh) })
|
|
|
|
let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq"))
|
|
locationManager.select(target)
|
|
#expect(await waitUntil { context.switchedChannels.last == target })
|
|
}
|
|
|
|
@Test @MainActor
|
|
func bookmarkChanges_beginAndEndGeohashSampling() async {
|
|
TorManager.shared.setAppForeground(true)
|
|
let locationManager = makeLocationManager()
|
|
let context = MockGeoChannelContext()
|
|
let coordinator = GeoChannelCoordinator(
|
|
locationManager: locationManager,
|
|
bookmarksStore: locationManager,
|
|
torManager: TorManager.shared,
|
|
context: context
|
|
)
|
|
|
|
// No geohashes yet: only end-sampling has run.
|
|
#expect(await waitUntil { context.endSamplingCount > 0 })
|
|
#expect(context.beginSamplingCalls.isEmpty)
|
|
|
|
// Bookmarking a geohash starts sampling it.
|
|
locationManager.toggleBookmark("u4pruydq")
|
|
#expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] })
|
|
|
|
// Removing the last bookmark ends sampling again, and a manual
|
|
// refresh keeps reporting the empty state.
|
|
let endCountBeforeRemoval = context.endSamplingCount
|
|
locationManager.toggleBookmark("u4pruydq")
|
|
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval })
|
|
|
|
let endCountBeforeRefresh = context.endSamplingCount
|
|
coordinator.refreshSampling()
|
|
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh })
|
|
}
|
|
|
|
@Test @MainActor
|
|
func buildingCellJoinsSamplingOnlyAfterNotesReveal() async {
|
|
TorManager.shared.setAppForeground(true)
|
|
let locationManager = makeLocationManager()
|
|
let context = MockGeoChannelContext()
|
|
let revealed = CurrentValueSubject<Bool, Never>(false)
|
|
let coordinator = GeoChannelCoordinator(
|
|
locationManager: locationManager,
|
|
bookmarksStore: locationManager,
|
|
torManager: TorManager.shared,
|
|
notesRevealed: revealed.eraseToAnyPublisher(),
|
|
context: context
|
|
)
|
|
defer { withExtendedLifetime(coordinator) {} }
|
|
|
|
// A location fix yields all six channel levels…
|
|
locationManager.locationManager(
|
|
CLLocationManager(),
|
|
didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)]
|
|
)
|
|
#expect(await waitUntil {
|
|
locationManager.availableChannels.contains { $0.level == .building }
|
|
})
|
|
let building = locationManager.availableChannels.first { $0.level == .building }!.geohash
|
|
|
|
// …but pre-reveal sampling must exclude the building-precision cell:
|
|
// a passive precision-8 REQ identifies a single address.
|
|
#expect(await waitUntil {
|
|
(context.beginSamplingCalls.last?.count ?? 0) == GeohashChannelLevel.allCases.count - 1
|
|
})
|
|
#expect(context.beginSamplingCalls.allSatisfy { !$0.contains(building) })
|
|
|
|
// The explicit notes act widens sampling to include it.
|
|
revealed.send(true)
|
|
#expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true })
|
|
#expect(context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
|
|
let locationManager = makeLocationManager()
|
|
var context: MockGeoChannelContext? = MockGeoChannelContext()
|
|
weak var weakContext = context
|
|
let coordinator = GeoChannelCoordinator(
|
|
locationManager: locationManager,
|
|
bookmarksStore: locationManager,
|
|
torManager: TorManager.shared,
|
|
context: context!
|
|
)
|
|
|
|
#expect(await waitUntil { context?.switchedChannels.isEmpty == false })
|
|
|
|
// The coordinator must not keep the owner alive (it is owned by it).
|
|
context = nil
|
|
#expect(weakContext == nil)
|
|
|
|
// Events after the owner is gone are safely dropped.
|
|
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
|
|
locationManager.toggleBookmark("u4pruydq")
|
|
coordinator.refreshSampling()
|
|
for _ in 0..<10 { await Task.yield() }
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
}
|