sustituir Crashlytics por un recolector MetricKit que no sale del dispositivo

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
alexandrev-tibco
2026-09-18 16:09:45 +02:00
parent 7d2f605c16
commit 2380d37a9e
10 changed files with 178 additions and 0 deletions
+5
View File
@@ -6,6 +6,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool { ) -> 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 // Request notification permissions
requestNotificationPermissions() requestNotificationPermissions()
@@ -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_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_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."; "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.";
@@ -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_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_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."; "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.";
@@ -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_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_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."; "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.";
@@ -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_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_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."; "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.";
@@ -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_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_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."; "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.";
@@ -703,3 +703,7 @@
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。"; "whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID"; "whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。"; "whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
"Share Diagnostics" = "診断情報を共有";
"diagnostics_footer" = "iOS がこの端末で収集したクラッシュとハングのレポートです。どこにも送信されません。共有した場合だけ iPhone の外に出ます。";
@@ -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_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_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."; "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.";
@@ -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")
}
}
}
@@ -881,11 +881,35 @@ struct SettingsView: View {
.foregroundStyle(.yellow) .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: { } header: {
Text("About") 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 // MARK: - Danger Zone Section
private var dangerZoneSection: some View { private var dangerZoneSection: some View {