Files
InvestmentTrackerApp/PortfolioJournal/App/PortfolioJournalApp.swift
T
alexandrev-tibco 7d2f605c16 quitar AdMob, Firebase Analytics y ATT de la app
"Portfolio Journal sin AdMob ni Google: es la única forma de que 'sin
rastreo' sea verdad, y el ingreso por anuncios es despreciable" — la ficha
de App Store prometía "sin analíticas, sin rastreo, sin venta de datos"
mientras la app enlazaba GoogleMobileAds y FirebaseAnalytics, pedía permiso
de rastreo al arrancar y mandaba el importe del saldo a GA4 en el evento
snapshot_added.

SDKs fuera del proyecto (project.pbxproj): paquetes firebase-ios-sdk y
swift-package-manager-google-mobile-ads, productos FirebaseCore,
FirebaseAnalytics, GoogleMobileAds y FirebaseCrashlytics, más la fase de
build "Upload dSYMs to Crashlytics". Package.resolved se queda sin nada que
resolver. Con la fase de script fuera, ENABLE_USER_SCRIPT_SANDBOXING vuelve
a YES en el target app (estaba en NO solo por Crashlytics).

Código borrado:
- Services/AdMobService.swift entero — con él se van el flujo UMP,
  BannerAdView, BannerAdCoordinator y la llamada a
  ATTrackingManager.requestTrackingAuthorization().
- Services/FirebaseService.swift entero y sus 40 llamadas. Se borra en vez
  de dejarse como capa vacía: una clase llamada FirebaseService en una app
  que presume de no llevar Firebase es exactamente la clase de detalle que
  vuelve a colarse en una auditoría dentro de un año.
- El banner y su safeAreaInset en ContentView (bannerInsetView): las cinco
  pestañas ya no reservan 50 pt al pie.
- La entrada "Manage Ad Consent" de Ajustes y el @EnvironmentObject
  adMobService de SettingsView, ContentView y PortfolioJournalApp.
- AppConstants: bloque AdMob, bannerAdHeight, adConsentObtained,
  Features.enableAnalytics.
- SettingsViewModel.toggleAnalytics y analyticsEnabled — el flag no estaba
  conectado a nada ni tenía control en la interfaz. El atributo
  AppSettings.enableAnalytics se queda en CoreData con un comentario: no
  vale la pena una migración y un deploy de esquema CloudKit por borrarlo.
- Premium ya no promete "sin anuncios": fuera PremiumFeature.noAds, la
  entrada de IAPService.premiumFeatures y paywallBenefits, y las claves
  feature_no_ads / paywall_benefit_noads de los 7 idiomas. No se toca ni el
  precio ni el producto.

Info.plist: fuera NSUserTrackingUsageDescription, GADApplicationIdentifier,
GADDelayAppMeasurementInit y los 65 SKAdNetworkItems. Borrados también
GoogleService-Info.plist y los scripts Scripts/analyze_ga4.py y
Scripts/analyze_crashlytics.py, que ya no tienen de dónde leer.

Closes #50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:04 +02:00

138 lines
6.6 KiB
Swift

import SwiftUI
import CoreData
import TipKit
/// Support for automated App Store screenshot capture. Activated by launching the app
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
/// state when that argument is present, so it never affects normal runs.
enum ScreenshotMode {
static var isActive: Bool { CommandLine.arguments.contains("--screenshots") }
/// Skip onboarding, biometric/PIN lock and the What's New sheet so the app opens
/// straight into content. Called very early, before ContentView reads @AppStorage.
static func applyDefaultsIfNeeded() {
guard isActive else { return }
let d = UserDefaults.standard
d.set(true, forKey: "onboardingCompleted")
d.set(false, forKey: "faceIdEnabled")
d.set(false, forKey: "pinEnabled")
d.set(false, forKey: "lockOnLaunch")
d.set(false, forKey: "lockOnBackground")
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
d.set(version, forKey: "lastSeenWhatsNewVersion")
// Unlock premium (DEBUG-only override) so captures/UI tests can exercise
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
// off to test the free-tier experience (locked charts, teasers).
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
// TipKit popovers cover toolbar buttons and swallow the first tap in
// automated walkthroughs keep them out of captures.
Tips.hideAllTipsForTesting()
}
/// Seed demo data (no-op if the store already has sources). Called once Core Data
/// has finished loading so the view context is ready.
static func seedIfNeeded() {
guard isActive else { return }
SampleDataService.shared.seedSampleData()
makeCheckInPendingIfNeeded()
}
/// `--pending-checkin`: strip the seeded data back to a "month not done yet"
/// state (recent snapshots + this month's journal completion removed) so the
/// guided check-in flow can be exercised/captured.
private static func makeCheckInPendingIfNeeded() {
guard CommandLine.arguments.contains("--pending-checkin") else { return }
let context = CoreDataStack.shared.viewContext
let cutoff = Calendar.current.date(byAdding: .day, value: -40, to: Date()) ?? Date()
let snapshotRequest = Snapshot.fetchRequest()
snapshotRequest.predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
(try? context.fetch(snapshotRequest))?.forEach(context.delete)
let journalRequest = JournalEntry.fetchRequest()
journalRequest.predicate = NSPredicate(format: "completionTime >= %@", cutoff as NSDate)
(try? context.fetch(journalRequest))?.forEach(context.delete)
try? context.save()
SharedQuickUpdateSync.refreshMirror()
}
}
@main
struct PortfolioJournalApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var iapService: IAPService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
@Environment(\.scenePhase) private var scenePhase
let coreDataStack = CoreDataStack.shared
init() {
// Screenshot/UI-capture mode: skip onboarding, lock and What's New so the app
// launches straight into content with demo data (see ScreenshotMode).
ScreenshotMode.applyDefaultsIfNeeded()
// Clean up any duplicate objects from previous bugs before initializing stores
CoreDataStack.shared.cleanupDuplicateObjects()
// Feature-discovery tips (Quick Update, OCR, Siri, chart sharing)
try? Tips.configure([.displayFrequency(.daily)])
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(coreDataStack)
.environmentObject(iapService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
// Safety net for any save path that doesn't refresh the widget
// itself: guarantee the widget is fresh whenever the app leaves
// the foreground (WAL checkpoint + timeline reload).
coreDataStack.refreshWidgetData()
}
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
// iCloud changes made on other devices while this device was inactive
// are reflected immediately without waiting for a remote-change notification.
coreDataStack.refreshFromCloudKit()
// Auto-backup: writes a lossless JSON snapshot on the configured cadence.
DispatchQueue.global(qos: .utility).async {
BackupService.shared.runScheduledBackupIfNeeded()
}
NotificationService.shared.scheduleReEngagementNotification()
NotificationService.shared.scheduleMonthlyCheckIn()
NotificationService.shared.scheduleStreakProtectionReminder()
// Share-extension bridge: apply values captured from other apps and
// refresh the source mirror the extension reads.
if coreDataStack.isLoaded {
SharedQuickUpdateSync.ingestPending()
SharedQuickUpdateSync.refreshMirror()
}
} else if newPhase == .background {
guard iapService.isPremium else { return }
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
let retention = UserDefaults.standard.integer(forKey: "backupRetentionCount")
let keepCount = [5, 10, 20].contains(retention) ? retention : 10
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
_ = BackupService.shared.createBackup(
retentionCount: keepCount,
includeICloud: includeICloud
)
}
}
}
}