From 2380d37a9e571840b07f2cef0f5836b5403055c8 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 18 Sep 2026 16:09:45 +0200 Subject: [PATCH] sustituir Crashlytics por un recolector MetricKit que no sale del dispositivo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FirebaseCrashlytics se llevaba los informes de fallo a Google. Los crashes ya llegan a Xcode Organizer por la vía de Apple, así que el sustituto no tiene que enviar nada: DiagnosticsCollector se suscribe a MXMetricManager, guarda los payloads de crash, cuelgue y escritura en disco como JSON en Application Support/Diagnostics y ahí se quedan. - Sin código de red. Retención de 5 informes, los ficheros se marcan como excluidos de backup (son ayuda de depuración, no datos del usuario). - Arranque: AppDelegate llama a DiagnosticsCollector.shared.start() en lugar de FirebaseApp.configure() + MobileAds.shared.start(). - Ajustes → Acerca de: "Compartir diagnósticos", visible solo cuando hay algo recogido. Compartir es la única forma de que un informe salga del iPhone, y la decide el usuario. Cadenas nuevas en los 7 idiomas. Un recolector que guarda y no enseña sería código muerto, y uno que sube sería el problema de antes con otro nombre; por eso guarda en local y deja el envío como acción explícita. Closes #51 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn --- PortfolioJournal/App/AppDelegate.swift | 5 + .../Resources/de.lproj/Localizable.strings | 4 + .../Resources/en.lproj/Localizable.strings | 4 + .../Resources/es-ES.lproj/Localizable.strings | 4 + .../Resources/fr.lproj/Localizable.strings | 4 + .../Resources/it.lproj/Localizable.strings | 4 + .../Resources/ja.lproj/Localizable.strings | 4 + .../Resources/pt-BR.lproj/Localizable.strings | 4 + .../Services/DiagnosticsCollector.swift | 121 ++++++++++++++++++ .../Views/Settings/SettingsView.swift | 24 ++++ 10 files changed, 178 insertions(+) create mode 100644 PortfolioJournal/Services/DiagnosticsCollector.swift diff --git a/PortfolioJournal/App/AppDelegate.swift b/PortfolioJournal/App/AppDelegate.swift index 69abc30..b8c5a9e 100644 --- a/PortfolioJournal/App/AppDelegate.swift +++ b/PortfolioJournal/App/AppDelegate.swift @@ -6,6 +6,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // On-device crash/hang diagnostics. Nothing is uploaded anywhere: reports + // stay in the app container and Apple's own pipeline is what feeds the + // Xcode Organizer. See DiagnosticsCollector. + DiagnosticsCollector.shared.start() + // Request notification permissions requestNotificationPermissions() diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index b44cd0c..f01d4ce 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -703,3 +703,7 @@ "whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich – das Dashboard bleibt sichtbar, während du Werte eingibst."; "whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID"; "whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Diagnose teilen"; +"diagnostics_footer" = "Absturz- und Hänger-Berichte, die iOS auf diesem Gerät gesammelt hat. Sie werden nirgendwohin übertragen — nur durch Teilen verlässt ein Bericht dein iPhone."; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 26480f7..dc297a9 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -704,3 +704,7 @@ "whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values."; "whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID"; "whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Share Diagnostics"; +"diagnostics_footer" = "Crash and hang reports collected by iOS on this device. They are never uploaded anywhere — sharing one is the only way it leaves your iPhone."; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 985197d..a3fd456 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -657,3 +657,7 @@ "whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores."; "whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID"; "whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Compartir diagnósticos"; +"diagnostics_footer" = "Informes de fallos y bloqueos que iOS ha recogido en este dispositivo. Nunca se envían a ningún sitio: compartir uno es la única forma de que salga de tu iPhone."; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index e9403c7..71975ec 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -703,3 +703,7 @@ "whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie."; "whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID"; "whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Partager les diagnostics"; +"diagnostics_footer" = "Rapports de plantage et de blocage collectés par iOS sur cet appareil. Ils ne sont jamais envoyés nulle part : les partager est le seul moyen qu'ils quittent votre iPhone."; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 7c344d7..4992788 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -703,3 +703,7 @@ "whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori."; "whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID"; "whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Condividi diagnostica"; +"diagnostics_footer" = "Rapporti di crash e blocchi raccolti da iOS su questo dispositivo. Non vengono mai inviati da nessuna parte: condividerne uno è l'unico modo per farlo uscire dal tuo iPhone."; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 2d132e9..7c31829 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -703,3 +703,7 @@ "whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。"; "whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID"; "whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。"; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "診断情報を共有"; +"diagnostics_footer" = "iOS がこの端末で収集したクラッシュとハングのレポートです。どこにも送信されません。共有した場合だけ iPhone の外に出ます。"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index b47c7aa..ef93d82 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -703,3 +703,7 @@ "whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores."; "whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID"; "whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho."; + +/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */ +"Share Diagnostics" = "Compartilhar diagnósticos"; +"diagnostics_footer" = "Relatórios de falhas e travamentos coletados pelo iOS neste dispositivo. Nunca são enviados para lugar nenhum: compartilhar um é a única forma de ele sair do seu iPhone."; diff --git a/PortfolioJournal/Services/DiagnosticsCollector.swift b/PortfolioJournal/Services/DiagnosticsCollector.swift new file mode 100644 index 0000000..f793e5f --- /dev/null +++ b/PortfolioJournal/Services/DiagnosticsCollector.swift @@ -0,0 +1,121 @@ +import Foundation +import MetricKit +import os + +/// On-device replacement for Crashlytics. +/// +/// `MXMetricManager` hands the app a daily payload with crash, hang and +/// disk-write diagnostics that Apple already collected for it. This collector +/// writes those payloads to the app container as JSON and **never sends them +/// anywhere** — no network code, no third-party SDK, no identifiers. Crash +/// reports still reach Xcode Organizer through Apple's own pipeline, which is +/// the channel that was actually useful about Crashlytics. +/// +/// The user can share the stored reports from Settings; that is the only way +/// one ever leaves the device, and it is an explicit action. +final class DiagnosticsCollector: NSObject { + static let shared = DiagnosticsCollector() + + /// Reports older than this are pruned on every new payload. + private static let retentionCount = 5 + + private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "diagnostics") + private let fileManager = FileManager.default + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + private override init() { + super.init() + } + + /// Subscribes to MetricKit. Safe to call more than once. + func start() { + MXMetricManager.shared.add(self) + } + + // MARK: - Storage + + /// `Application Support/Diagnostics`, created on demand. + private var directory: URL? { + guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let url = base.appendingPathComponent("Diagnostics", isDirectory: true) + if !fileManager.fileExists(atPath: url.path) { + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + return url + } + + /// Stored reports, newest first. Used by Settings to offer sharing them. + func storedReports() -> [URL] { + guard let directory else { return [] } + let contents = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + return contents + .filter { $0.pathExtension == "json" } + .sorted { lhs, rhs in + let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return l > r + } + } + + func deleteStoredReports() { + for url in storedReports() { + try? fileManager.removeItem(at: url) + } + } + + private func write(_ data: Data, prefix: String) { + guard let directory else { return } + let name = "\(prefix)-\(dateFormatter.string(from: Date())).json" + let url = directory.appendingPathComponent(name) + do { + try data.write(to: url, options: .atomic) + // Diagnostics are debugging aids, not user data: keep them out of + // iCloud/iTunes backups. + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var mutableURL = url + try? mutableURL.setResourceValues(resourceValues) + logger.info("Stored diagnostic report \(name, privacy: .public)") + } catch { + logger.error("Could not store diagnostic report: \(error.localizedDescription, privacy: .public)") + } + prune() + } + + private func prune() { + let reports = storedReports() + guard reports.count > Self.retentionCount else { return } + for url in reports.dropFirst(Self.retentionCount) { + try? fileManager.removeItem(at: url) + } + } +} + +// MARK: - MXMetricManagerSubscriber + +extension DiagnosticsCollector: MXMetricManagerSubscriber { + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + write(payload.jsonRepresentation(), prefix: "metrics") + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + let crashes = payload.crashDiagnostics?.count ?? 0 + let hangs = payload.hangDiagnostics?.count ?? 0 + logger.info("Diagnostic payload: \(crashes, privacy: .public) crash(es), \(hangs, privacy: .public) hang(s)") + write(payload.jsonRepresentation(), prefix: "diagnostics") + } + } +} diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index 5de83f8..d35d162 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -881,11 +881,35 @@ struct SettingsView: View { .foregroundStyle(.yellow) } } + + // Only shown when MetricKit has actually left something behind. + // Sharing is the only way a report leaves the device. + if let latestReport = diagnosticReports.first { + Button { + viewModel.shareItem = SettingsViewModel.ShareItem(url: latestReport) + } label: { + HStack { + Text("Share Diagnostics") + Spacer() + Image(systemName: "square.and.arrow.up") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } } header: { Text("About") + } footer: { + if !diagnosticReports.isEmpty { + Text("diagnostics_footer") + } } } + /// Crash/hang reports MetricKit left in the app container. They never leave + /// the device unless the user shares one from here. + private var diagnosticReports: [URL] { DiagnosticsCollector.shared.storedReports() } + // MARK: - Danger Zone Section private var dangerZoneSection: some View {