fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -223,13 +223,23 @@ final class DebugShareExporter {
|
||||
|
||||
// Pick a few representative achievements across sports
|
||||
let defs = AchievementRegistry.all
|
||||
let sampleDefs = [
|
||||
defs.first { $0.sport == .mlb } ?? defs[0],
|
||||
defs.first { $0.sport == .nba } ?? defs[1],
|
||||
defs.first { $0.sport == .nhl } ?? defs[2],
|
||||
defs.first { $0.name.lowercased().contains("complete") } ?? defs[3],
|
||||
defs.first { $0.category == .journey } ?? defs[min(4, defs.count - 1)]
|
||||
guard !defs.isEmpty else {
|
||||
exportPath = exportDir.path
|
||||
currentStep = "Export complete! (no achievements found)"
|
||||
isExporting = false
|
||||
return
|
||||
}
|
||||
let fallback = defs[0]
|
||||
var sampleDefs: [AchievementDefinition] = [
|
||||
defs.first { $0.sport == .mlb } ?? fallback,
|
||||
defs.first { $0.sport == .nba } ?? fallback,
|
||||
defs.first { $0.sport == .nhl } ?? fallback,
|
||||
defs.first { $0.name.lowercased().contains("complete") } ?? fallback,
|
||||
defs.first { $0.category == .journey } ?? fallback
|
||||
]
|
||||
// Deduplicate in case multiple fallbacks resolved to the same definition
|
||||
var seen = Set<String>()
|
||||
sampleDefs = sampleDefs.filter { seen.insert($0.id).inserted }
|
||||
|
||||
totalCount = sampleDefs.count
|
||||
|
||||
@@ -407,9 +417,9 @@ final class DebugShareExporter {
|
||||
static func buildSamplePoll() -> TripPoll {
|
||||
let trips = buildDummyTrips()
|
||||
let sampleVotes = [
|
||||
PollVote(pollId: UUID(), odg: "voter1", rankings: [0, 2, 1, 3]),
|
||||
PollVote(pollId: UUID(), odg: "voter2", rankings: [2, 0, 3, 1]),
|
||||
PollVote(pollId: UUID(), odg: "voter3", rankings: [0, 1, 2, 3]),
|
||||
PollVote(pollId: UUID(), voterId: "voter1", rankings: [0, 2, 1, 3]),
|
||||
PollVote(pollId: UUID(), voterId: "voter2", rankings: [2, 0, 3, 1]),
|
||||
PollVote(pollId: UUID(), voterId: "voter3", rankings: [0, 1, 2, 3]),
|
||||
]
|
||||
_ = sampleVotes // votes are shown via PollResults, we pass them separately
|
||||
|
||||
@@ -422,9 +432,9 @@ final class DebugShareExporter {
|
||||
|
||||
static func buildSampleVotes(for poll: TripPoll) -> [PollVote] {
|
||||
[
|
||||
PollVote(pollId: poll.id, odg: "voter-alex", rankings: [0, 2, 1, 3]),
|
||||
PollVote(pollId: poll.id, odg: "voter-sam", rankings: [2, 0, 3, 1]),
|
||||
PollVote(pollId: poll.id, odg: "voter-jordan", rankings: [0, 1, 2, 3]),
|
||||
PollVote(pollId: poll.id, voterId: "voter-alex", rankings: [0, 2, 1, 3]),
|
||||
PollVote(pollId: poll.id, voterId: "voter-sam", rankings: [2, 0, 3, 1]),
|
||||
PollVote(pollId: poll.id, voterId: "voter-jordan", rankings: [0, 1, 2, 3]),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -239,11 +239,9 @@ struct SportsIconImageGeneratorView: View {
|
||||
private func generateNewImage() {
|
||||
isGenerating = true
|
||||
|
||||
// Generate on background thread to avoid UI freeze
|
||||
// Generate image (UIKit drawing requires main thread)
|
||||
Task {
|
||||
let image = await Task.detached(priority: .userInitiated) {
|
||||
SportsIconImageGenerator.generateImage()
|
||||
}.value
|
||||
let image = SportsIconImageGenerator.generateImage()
|
||||
|
||||
withAnimation {
|
||||
generatedImage = image
|
||||
|
||||
@@ -807,15 +807,6 @@ struct SettingsView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.upgradeProButton")
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await StoreManager.shared.restorePurchases(source: "settings")
|
||||
}
|
||||
} label: {
|
||||
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityIdentifier("settings.restorePurchasesButton")
|
||||
|
||||
Button {
|
||||
showRedeemCode = true
|
||||
} label: {
|
||||
@@ -823,6 +814,16 @@ struct SettingsView: View {
|
||||
}
|
||||
.accessibilityIdentifier("settings.redeemCodeButton")
|
||||
}
|
||||
|
||||
// Restore Purchases available to ALL users (not just free users)
|
||||
Button {
|
||||
Task {
|
||||
await StoreManager.shared.restorePurchases(source: "settings")
|
||||
}
|
||||
} label: {
|
||||
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityIdentifier("settings.restorePurchasesButton")
|
||||
} header: {
|
||||
Text("Subscription")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user