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
This commit is contained in:
alexandrev-tibco
2026-09-18 16:09:04 +02:00
parent 319ab9589e
commit 7d2f605c16
39 changed files with 23 additions and 1503 deletions
+9 -7
View File
@@ -48,7 +48,7 @@ PortfolioJournal/
│ ├── Security/ # Face ID/PIN lock (AppLockView)
│ └── Components/ # Shared UI components
├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
└── Resources/ # Info.plist, PrivacyInfo.xcprivacy, assets
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
PortfolioJournalWatch/ # watchOS app (read-only portfolio view)
@@ -84,23 +84,25 @@ Data flows through `CoreDataStack` singleton which manages CloudKit sync and App
- **CalculationService**: Portfolio metrics, returns calculation, allocation analysis
- **PredictionEngine**: Investment forecasting algorithms with caching
- **IAPService**: StoreKit 2 in-app purchases
- **AdMobService**: Google Mobile Ads integration
- **DiagnosticsCollector**: on-device MetricKit crash/hang reports (nothing is uploaded)
- **ImportService/ExportService**: CSV data import/export
- **AppLockService**: Biometric/PIN security via Keychain
## Dependencies
Managed via Swift Package Manager:
- Firebase iOS SDK (v12.7.0+) - Analytics
- Google Mobile Ads SDK
**No third-party packages.** The app deliberately links nothing outside Apple's SDKs:
AdMob, Firebase Analytics and Crashlytics were removed in 1.7.0 so that the
"no tracking, no analytics" claim on the App Store listing is literally true.
Do not reintroduce an advertising, analytics or crash-reporting SDK.
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2,
LocalAuthentication, MetricKit
## App Initialization Flow
```
PortfolioJournalApp (@main)
└── AppDelegate (Firebase, AdMob, Notifications init)
└── AppDelegate (MetricKit diagnostics, Notifications init)
└── ContentView
├── OnboardingView (first launch)
├── AppLockView (if security enabled)
+2 -95
View File
@@ -10,11 +10,7 @@
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; };
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; };
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752A2F0FD08100F31390 /* FirebaseCore */; };
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH190000000000000000 /* PortfolioJournalWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EWTCH250000000000000000 /* PortfolioJournalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -246,11 +242,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */,
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -355,7 +347,6 @@
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
0E8318932F0DB2FB0030C2F9 /* Resources */,
0EWTCH240000000000000000 /* Embed Watch Content */,
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
);
buildRules = (
);
@@ -368,12 +359,6 @@
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
);
name = PortfolioJournal;
packageProductDependencies = (
0E53752A2F0FD08100F31390 /* FirebaseCore */,
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
);
productName = PortfolioJournal;
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
productType = "com.apple.product-type.application";
@@ -394,8 +379,6 @@
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
);
name = PortfolioJournalWidgetExtension;
packageProductDependencies = (
);
productName = PortfolioJournalWidgetExtension;
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
@@ -417,8 +400,6 @@
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
);
name = PortfolioJournalTests;
packageProductDependencies = (
);
productName = PortfolioJournalTests;
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -440,8 +421,6 @@
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
);
name = PortfolioJournalUITests;
packageProductDependencies = (
);
productName = PortfolioJournalUITests;
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -462,8 +441,6 @@
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
);
name = PortfolioJournalQuickUpdate;
packageProductDependencies = (
);
productName = PortfolioJournalQuickUpdate;
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
productType = "com.apple.product-type.app-extension";
@@ -487,8 +464,6 @@
0EWTCH070000000000000000 /* Shared */,
);
name = PortfolioJournalWatch;
packageProductDependencies = (
);
productName = PortfolioJournalWatch;
productReference = 0EWTCH030000000000000000 /* PortfolioJournalWatch.app */;
productType = "com.apple.product-type.application";
@@ -509,8 +484,6 @@
0EWTCH060000000000000000 /* PortfolioJournalWatchWidget */,
);
name = PortfolioJournalWatchWidget;
packageProductDependencies = (
);
productName = PortfolioJournalWatchWidget;
productReference = 0EWTCH040000000000000000 /* PortfolioJournalWatchWidget.appex */;
productType = "com.apple.product-type.app-extension";
@@ -555,10 +528,6 @@
);
mainGroup = 0E241E302F0DA93A00283E2F;
minimizedProjectReferenceProxies = 1;
packageReferences = (
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */;
projectDirPath = "";
@@ -679,28 +648,6 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "Upload dSYMs to Crashlytics";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXTargetDependency section */
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
@@ -742,7 +689,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 89;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
@@ -782,7 +729,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
@@ -1372,47 +1319,7 @@
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 12.7.0;
};
};
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 12.14.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
0E53752A2F0FD08100F31390 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseAnalytics;
};
0E5375302F0FD12E00F31390 /* GoogleMobileAds */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
productName = GoogleMobileAds;
};
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCrashlytics;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
}
@@ -1,150 +0,0 @@
{
"originHash" : "2d3f06aab1ccdfff7b100ca10208a834bfb664b3c50890610f1e3c349eb3fbf3",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef",
"version" : "12.7.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
"version" : "3.2.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
"version" : "12.5.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
"version" : "5.0.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "swift-package-manager-google-mobile-ads",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads",
"state" : {
"revision" : "1239c23464503053c17d37ee5daa70de3455e9c8",
"version" : "12.14.0"
}
},
{
"identity" : "swift-package-manager-google-user-messaging-platform",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
"state" : {
"revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b",
"version" : "3.1.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
}
],
"version" : 3
}
-14
View File
@@ -1,25 +1,11 @@
import UIKit
import UserNotifications
import FirebaseCore
import FirebaseAnalytics
import FirebaseCrashlytics
import GoogleMobileAds
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Initialize Firebase (only if GoogleService-Info.plist exists)
if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
FirebaseApp.configure()
} else {
print("Warning: GoogleService-Info.plist not found. Firebase disabled.")
}
// Initialize Google Mobile Ads
MobileAds.shared.start()
// Request notification permissions
requestNotificationPermissions()
+5 -18
View File
@@ -31,7 +31,6 @@ enum AppTab: Int, Hashable, CaseIterable {
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@EnvironmentObject var tabSelection: TabSelectionStore
@EnvironmentObject var coreDataStack: CoreDataStack
@AppStorage("onboardingCompleted") private var onboardingCompleted = false
@@ -288,26 +287,15 @@ struct ContentView: View {
private func tabContent(for tab: AppTab) -> some View {
switch tab {
case .dashboard:
bannerInsetView(DashboardView())
DashboardView()
case .sources:
bannerInsetView(SourceListView(iapService: iapService))
SourceListView(iapService: iapService)
case .charts:
bannerInsetView(ChartsContainerView(iapService: iapService))
ChartsContainerView(iapService: iapService)
case .journal:
bannerInsetView(JournalView())
JournalView()
case .settings:
bannerInsetView(SettingsView(iapService: iapService))
}
}
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium && adMobService.canShowAds {
BannerAdView()
.frame(height: AppConstants.UI.bannerAdHeight)
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
}
SettingsView(iapService: iapService)
}
}
}
@@ -316,7 +304,6 @@ struct ContentView: View {
ContentView()
.environmentObject(CoreDataStack.shared)
.environmentObject(IAPService())
.environmentObject(AdMobService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
.environmentObject(BalancePrivacyManager.shared)
@@ -62,7 +62,6 @@ enum ScreenshotMode {
struct PortfolioJournalApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var iapService: IAPService
@StateObject private var adMobService: AdMobService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
@@ -83,7 +82,6 @@ struct PortfolioJournalApp: App {
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_adMobService = StateObject(wrappedValue: AdMobService())
_accountStore = StateObject(wrappedValue: AccountStore(iapService: iap))
}
@@ -93,7 +91,6 @@ struct PortfolioJournalApp: App {
.environment(\.managedObjectContext, coreDataStack.viewContext)
.environmentObject(coreDataStack)
.environmentObject(iapService)
.environmentObject(adMobService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
@@ -10,6 +10,9 @@ public class AppSettings: NSManagedObject, Identifiable {
@NSManaged public var id: UUID
@NSManaged public var currency: String
@NSManaged public var defaultNotificationTime: Date?
/// Unused since 1.7.0: there is no analytics of any kind to enable or
/// disable. The attribute stays only because dropping it would mean a Core
/// Data migration plus a CloudKit schema deploy. Do not wire it to anything.
@NSManaged public var enableAnalytics: Bool
@NSManaged public var onboardingCompleted: Bool
@NSManaged public var lastSyncDate: Date?
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAregLaXQ-WqRQTltTRyjQx3-lfxLl4Cng</string>
<key>GCM_SENDER_ID</key>
<string>334225114072</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.alexandrevazquez.PortfolioJournal</string>
<key>PROJECT_ID</key>
<string>portfoliojournal-ef2d7</string>
<key>STORAGE_BUCKET</key>
<string>portfoliojournal-ef2d7.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:334225114072:ios:a0a50b5835ac042e3d28ad</string>
</dict>
</plist>
-269
View File
@@ -31,10 +31,6 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
@@ -54,271 +50,6 @@
<string>Use Face ID to unlock your portfolio data.</string>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUserTrackingUsageDescription</key>
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4fzdc2evr5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4pfyvq9l8r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2fnua5tdw4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ydx93a7ass.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5a6flpkh64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>p78axxw29g.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v72qych5uu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ludvb6z3bs.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cp8zw746q7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3sh42y64q3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c6k4g5qg8m.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>s39g8k73mm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qy4746246.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>f38h382jlk.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>hs6bdukanm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v4nxqhlyqp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>wzmmz9fp6w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>yclnxrl5pm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>t38b2kh725.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>7ug5zh24hu.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>gta9lk7p23.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>vutu7akeur.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>y5ghdn5j9k.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n6fk4nfna4.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>v9wttpbfk9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>n38lu8286q.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>47vhws6wlr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ber7.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9t245vhmpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>eh6m2bh4zr.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>a2p9lx4jpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>22mmun2rn5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4468km3ulz.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2u9pt9hc89.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8s468mfl3y.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>klf5c3l5u5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ppxm28t8ap.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>ecpz2srf59.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>uw77j35x4d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>pwa73g5rt2.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>mlmmfzh3r3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>578prtvx9j.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>4dzt52r2t5.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>e5fvkxwrpn.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>8c4e2ghe7u.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>zq492l623r.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3rd42ekr43.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>3qcr597p9d.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>su67r6k2v3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbd757ywx3.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44n7hlldy6.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>k674qkevps.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>2rq3zucswp.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>5tjdwbrq8w.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>97r2b46745.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>b9bk5wbcq9.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>44jx6755aq.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>kbmxgpxpgc.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>tl55sbb4fm.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>9g2aggbj52.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>g2y4y55b64.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>c3frkrj4fj.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>r26jy69rpl.skadnetwork</string>
</dict>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>275upjj5gd.skadnetwork</string>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
"feature_export" = "Daten exportieren";
"feature_export_desc" = "Export nach CSV und JSON";
"feature_no_ads" = "Keine Werbung";
"feature_no_ads_desc" = "Dauerhaft werbefrei";
"paywall_benefit_history_title" = "Deine vollständige Historie";
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
"settings_title" = "Einstellungen";
"subscription" = "Abo";
"notifications" = "Benachrichtigungen";
@@ -109,8 +109,6 @@
"feature_predictions_desc" = "AI-powered 12-month forecasts";
"feature_export" = "Export Data";
"feature_export_desc" = "Export to CSV and JSON formats";
"feature_no_ads" = "No Ads";
"feature_no_ads_desc" = "Ad-free experience forever";
// Paywall benefits (1.2.0)
"paywall_benefit_history_title" = "Your complete history";
@@ -119,8 +117,6 @@
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place";
"paywall_benefit_forecasts_title" = "12-month forecasts";
"paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading";
"paywall_benefit_noads_title" = "No ads, ever";
"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions";
// MARK: - Settings
"settings_title" = "Settings";
@@ -154,8 +154,6 @@
"paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar";
"paywall_benefit_forecasts_title" = "Previsiones a 12 meses";
"paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera";
"paywall_benefit_noads_title" = "Sin anuncios, nunca";
"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones";
// MARK: - Onboarding pages (1.2.0)
"onboarding_clarity_title" = "Sabe exactamente dónde estás";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
"feature_export" = "Exporter les données";
"feature_export_desc" = "Export vers CSV et JSON";
"feature_no_ads" = "Sans publicité";
"feature_no_ads_desc" = "Expérience sans publicité à vie";
"paywall_benefit_history_title" = "Votre historique complet";
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
"paywall_benefit_noads_title" = "Aucune pub, jamais";
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
"settings_title" = "Réglages";
"subscription" = "Abonnement";
"notifications" = "Notifications";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
"feature_export" = "Esporta dati";
"feature_export_desc" = "Esporta in CSV e JSON";
"feature_no_ads" = "Niente pubblicità";
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
"paywall_benefit_history_title" = "La tua cronologia completa";
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
"settings_title" = "Impostazioni";
"subscription" = "Abbonamento";
"notifications" = "Notifiche";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "AI による12か月予測";
"feature_export" = "データを書き出し";
"feature_export_desc" = "CSV と JSON に書き出し";
"feature_no_ads" = "広告なし";
"feature_no_ads_desc" = "ずっと広告なしで使えます";
"paywall_benefit_history_title" = "完全な履歴";
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
"paywall_benefit_charts_title" = "傾向が見えるチャート";
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
"paywall_benefit_forecasts_title" = "12か月予測";
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
"paywall_benefit_noads_title" = "広告は一切なし";
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
"settings_title" = "設定";
"subscription" = "サブスクリプション";
"notifications" = "通知";
@@ -85,16 +85,12 @@
"feature_predictions_desc" = "Previsões de 12 meses com IA";
"feature_export" = "Exportar dados";
"feature_export_desc" = "Exporte para CSV e JSON";
"feature_no_ads" = "Sem anúncios";
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
"paywall_benefit_history_title" = "Seu histórico completo";
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
"settings_title" = "Ajustes";
"subscription" = "Assinatura";
"notifications" = "Notificações";
@@ -1,215 +0,0 @@
import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import UserMessagingPlatform
import AppTrackingTransparency
import AdSupport
@MainActor
class AdMobService: ObservableObject {
// MARK: - Published Properties
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
@Published var shouldRequestNonPersonalizedAds = false
// MARK: - Ad Unit IDs
// Ad Unit IDs
#if DEBUG
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test ad unit for development
#else
static let bannerAdUnitID = "ca-app-pub-1549720748100858/3342431241" // Production ad unit
#endif
// MARK: - Initialization
init() {
setupResetObserver()
Task {
await configureConsentAndRequestAdsIfNeeded()
}
}
// MARK: - Consent Management
func requestConsent() async {
await configureConsentAndRequestAdsIfNeeded()
}
func presentPrivacyOptions() async {
guard let root = topViewController() else { return }
await withCheckedContinuation { continuation in
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
continuation.resume()
}
}
updateConsentState()
}
func resetConsent() {
ConsentInformation.shared.reset()
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
isConsentObtained = false
canShowAds = false
shouldRequestNonPersonalizedAds = false
}
private func setupResetObserver() {
NotificationCenter.default.addObserver(
forName: .didResetData,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.resetConsent()
await self.configureConsentAndRequestAdsIfNeeded()
}
}
}
private func configureConsentAndRequestAdsIfNeeded() async {
isLoading = true
let parameters = RequestParameters()
await withCheckedContinuation { continuation in
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
continuation.resume()
}
}
if let root = topViewController() {
await withCheckedContinuation { continuation in
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
continuation.resume()
}
}
}
updateConsentState()
if canShowAds {
await requestTrackingIfNeeded()
updateConsentState()
}
isLoading = false
}
private func updateConsentState() {
let consentInfo = ConsentInformation.shared
canShowAds = consentInfo.canRequestAds
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
let trackingDenied: Bool
if #available(iOS 14.5, *) {
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
} else {
trackingDenied = false
}
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
}
private func requestTrackingIfNeeded() async {
if #available(iOS 14.5, *) {
_ = await ATTrackingManager.requestTrackingAuthorization()
}
}
private func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else {
return nil
}
var current = root
while let presented = current.presentedViewController {
current = presented
}
return current
}
// MARK: - Analytics
func logAdImpression() {
FirebaseService.shared.logAdImpression(adType: "banner")
}
func logAdClick() {
FirebaseService.shared.logAdClick(adType: "banner")
}
}
// MARK: - Banner Ad Coordinator
class BannerAdCoordinator: NSObject, BannerViewDelegate {
weak var adMobService: AdMobService?
func bannerViewDidReceiveAd(_ bannerView: BannerView) {
print("Banner ad received")
adMobService?.logAdImpression()
}
func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) {
print("Banner ad failed to load: \(error.localizedDescription)")
}
func bannerViewDidRecordImpression(_ bannerView: BannerView) {
// Impression recorded
}
func bannerViewDidRecordClick(_ bannerView: BannerView) {
adMobService?.logAdClick()
}
func bannerViewWillPresentScreen(_ bannerView: BannerView) {
// Ad will present full screen
}
func bannerViewWillDismissScreen(_ bannerView: BannerView) {
// Ad will dismiss
}
func bannerViewDidDismissScreen(_ bannerView: BannerView) {
// Ad dismissed
}
}
// MARK: - UIKit Banner View Wrapper
struct BannerAdView: UIViewRepresentable {
@EnvironmentObject var adMobService: AdMobService
func makeUIView(context: Context) -> BannerView {
let bannerView = BannerView(adSize: AdSizeBanner)
bannerView.adUnitID = AdMobService.bannerAdUnitID
// Get root view controller
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
bannerView.rootViewController = rootViewController
}
bannerView.delegate = context.coordinator
let request = Request()
if adMobService.shouldRequestNonPersonalizedAds {
let extras = Extras()
extras.additionalParameters = ["npa": "1"]
request.register(extras)
}
bannerView.load(request)
return bannerView
}
func updateUIView(_ uiView: BannerView, context: Context) {
// Banner updates automatically
}
func makeCoordinator() -> BannerAdCoordinator {
let coordinator = BannerAdCoordinator()
coordinator.adMobService = adMobService
return coordinator
}
}
@@ -45,7 +45,6 @@ final class ChartShareService {
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
FirebaseService.shared.logShare(type: "chart")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
+1 -10
View File
@@ -258,18 +258,9 @@ class ExportService {
)
}
viewController.present(activityVC, animated: true) {
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: true
)
}
viewController.present(activityVC, animated: true)
} catch {
print("Export error: \(error)")
FirebaseService.shared.logExportAttempt(
format: format.rawValue,
success: false
)
}
}
@@ -1,226 +0,0 @@
import Foundation
import FirebaseAnalytics
import FirebaseCore
class FirebaseService {
static let shared = FirebaseService()
private var isConfigured: Bool {
FirebaseApp.app() != nil
}
private init() {}
// MARK: - User Properties
func setUserTier(_ tier: UserTier) {
guard isConfigured else { return }
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
}
enum UserTier: String {
case free = "free"
case premium = "premium"
}
// MARK: - Screen Tracking
func logScreenView(screenName: String, screenClass: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
AnalyticsParameterScreenName: screenName,
AnalyticsParameterScreenClass: screenClass ?? screenName
])
}
// MARK: - Investment Events
func logSourceAdded(categoryName: String, sourceCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("source_added", parameters: [
"category_name": categoryName,
"total_sources": sourceCount
])
}
func logSourceDeleted(categoryName: String) {
guard isConfigured else { return }
Analytics.logEvent("source_deleted", parameters: [
"category_name": categoryName
])
}
func logSnapshotAdded(sourceName: String, value: Decimal) {
guard isConfigured else { return }
Analytics.logEvent("snapshot_added", parameters: [
"source_name": sourceName,
"value": NSDecimalNumber(decimal: value).doubleValue
])
}
func logCategoryCreated(name: String) {
guard isConfigured else { return }
Analytics.logEvent("category_created", parameters: [
"category_name": name
])
}
// MARK: - Purchase Events
func logPaywallShown(trigger: String) {
guard isConfigured else { return }
Analytics.logEvent("paywall_shown", parameters: [
"paywall_trigger": trigger
])
}
func logPurchaseAttempt(productId: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_attempt", parameters: [
"product_id": productId
])
}
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
guard isConfigured else { return }
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
AnalyticsParameterItemID: productId,
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
AnalyticsParameterCurrency: "EUR",
"is_family_shared": isFamilyShared
])
}
func logPurchaseFailure(productId: String, error: String) {
guard isConfigured else { return }
Analytics.logEvent("purchase_failed", parameters: [
"product_id": productId,
"error": error
])
}
func logRestorePurchases(success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("restore_purchases", parameters: [
"success": success
])
}
// MARK: - Feature Usage Events
func logChartViewed(chartType: String, isPremium: Bool) {
guard isConfigured else { return }
Analytics.logEvent("chart_viewed", parameters: [
"chart_type": chartType,
"is_premium_chart": isPremium
])
}
func logPredictionViewed(algorithm: String) {
guard isConfigured else { return }
Analytics.logEvent("prediction_viewed", parameters: [
"algorithm": algorithm
])
}
func logExportAttempt(format: String, success: Bool) {
guard isConfigured else { return }
Analytics.logEvent("export_attempt", parameters: [
"format": format,
"success": success
])
}
func logNotificationScheduled(frequency: String) {
guard isConfigured else { return }
Analytics.logEvent("notification_scheduled", parameters: [
"frequency": frequency
])
}
// MARK: - Ad Events
func logAdImpression(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_impression", parameters: [
"ad_type": adType
])
}
func logAdClick(adType: String) {
guard isConfigured else { return }
Analytics.logEvent("ad_click", parameters: [
"ad_type": adType
])
}
// MARK: - Engagement Events
func logOnboardingCompleted(stepCount: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_completed", parameters: [
"steps_completed": stepCount
])
}
/// Per-step onboarding funnel event lets us see exactly where users drop off
/// inside onboarding (only the final `onboarding_completed` existed before).
func logOnboardingStep(step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_step", parameters: [
"step": step
])
}
func logOnboardingSkipped(atStep step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_skipped", parameters: [
"step": step
])
}
/// Viral loop measurement: fired whenever the user opens a share sheet with
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
func logShare(type: String) {
guard isConfigured else { return }
Analytics.logEvent("content_shared", parameters: [
"share_type": type
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
"widget_type": widgetType
])
}
func logAppOpened(fromWidget: Bool) {
guard isConfigured else { return }
Analytics.logEvent("app_opened", parameters: [
"from_widget": fromWidget
])
}
// MARK: - Portfolio Events
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
guard isConfigured else { return }
Analytics.logEvent("portfolio_milestone", parameters: [
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
"milestone": milestone
])
}
// MARK: - Error Events
func logError(type: String, message: String, context: String? = nil) {
guard isConfigured else { return }
Analytics.logEvent("app_error", parameters: [
"error_type": type,
"error_message": message,
"context": context ?? ""
])
}
}
@@ -60,7 +60,6 @@ class GoalShareService {
estimatedCompletionDate: Date? = nil,
privacyMode: Bool = false
) {
FirebaseService.shared.logShare(type: "goal")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
+1 -16
View File
@@ -107,13 +107,6 @@ class IAPService: ObservableObject {
purchaseState = .purchased
// Track analytics
FirebaseService.shared.logPurchaseSuccess(
productId: product.id,
price: product.price,
isFamilyShared: isFamilyShared
)
case .userCancelled:
purchaseState = .idle
@@ -125,10 +118,6 @@ class IAPService: ObservableObject {
}
} catch {
purchaseState = .failed(error.localizedDescription)
FirebaseService.shared.logPurchaseFailure(
productId: product.id,
error: error.localizedDescription
)
throw error
}
}
@@ -312,7 +301,6 @@ extension IAPService {
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members")
]
@@ -333,9 +321,6 @@ extension IAPService {
String(localized: "paywall_benefit_accounts_subtitle")),
("person.3",
String(localized: "paywall_benefit_family_title"),
String(localized: "paywall_benefit_family_subtitle")),
("xmark.circle",
String(localized: "paywall_benefit_noads_title"),
String(localized: "paywall_benefit_noads_subtitle"))
String(localized: "paywall_benefit_family_subtitle"))
]}
}
@@ -95,7 +95,6 @@ class NotificationService: ObservableObject {
print("Failed to schedule notification: \(error)")
} else {
print("Scheduled reminder for \(source.name) on \(triggerDate)")
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
}
}
}
@@ -100,7 +100,6 @@ class ShareService {
@MainActor
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
FirebaseService.shared.logShare(type: "monthly_checkin")
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
shareCard(
cardTitle: "\(summary.periodLabel) Check-in",
@@ -127,7 +126,6 @@ class ShareService {
@MainActor
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
FirebaseService.shared.logShare(type: "monthly_summary")
let appName = Self.appDisplayName
var lines = [
@@ -178,7 +176,6 @@ class ShareService {
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let display = Self.portfolioCardStrings(
totalValue: totalValue,
@@ -17,16 +17,6 @@ enum AppConstants {
static let premiumProductID = "com.portfoliojournal.premium"
// MARK: - AdMob
#if DEBUG
static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID
static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner
#else
static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID
static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID
#endif
// MARK: - Currency
static let defaultCurrency = "EUR"
@@ -52,7 +42,6 @@ enum AppConstants {
static let smallIconSize: CGFloat = 16
static let largeIconSize: CGFloat = 32
static let bannerAdHeight: CGFloat = 50
static let tabBarHeight: CGFloat = 49
static let cardShadowRadius: CGFloat = 4
@@ -88,7 +77,6 @@ enum AppConstants {
enum StorageKeys {
static let onboardingCompleted = "onboardingCompleted"
static let adConsentObtained = "adConsentObtained"
static let lastSyncDate = "lastSyncDate"
static let selectedCategoryFilter = "selectedCategoryFilter"
static let preferredChartType = "preferredChartType"
@@ -119,7 +107,6 @@ enum AppConstants {
static let enableExport = true
static let enableWidgets = true
static let enableNotifications = true
static let enableAnalytics = true
}
}
@@ -128,8 +128,6 @@ class FreemiumValidator: ObservableObject {
return false
case .export:
return false
case .noAds:
return false
}
}
@@ -142,7 +140,6 @@ class FreemiumValidator: ObservableObject {
case advancedCharts = "advanced_charts"
case predictions = "predictions"
case export = "export"
case noAds = "no_ads"
var id: String { rawValue }
@@ -154,7 +151,6 @@ class FreemiumValidator: ObservableObject {
case .advancedCharts: return "Advanced Charts"
case .predictions: return "Predictions"
case .export: return "Export Data"
case .noAds: return "No Ads"
}
}
@@ -166,7 +162,6 @@ class FreemiumValidator: ObservableObject {
case .advancedCharts: return "chart.bar.xaxis"
case .predictions: return "wand.and.stars"
case .export: return "square.and.arrow.up"
case .noAds: return "xmark.circle"
}
}
@@ -184,8 +179,6 @@ class FreemiumValidator: ObservableObject {
return "AI-powered 12-month forecasts"
case .export:
return "Export to CSV and JSON formats"
case .noAds:
return "Ad-free experience forever"
}
}
}
@@ -405,8 +405,6 @@ class ChartsViewModel: ObservableObject {
category: selectedCategory,
timeRange: selectedTimeRange
)
FirebaseService.shared.logScreenView(screenName: "Charts")
}
/// Selection is NEVER blocked premium charts select normally and the chart
@@ -423,10 +421,6 @@ class ChartsViewModel: ObservableObject {
// observer: makes selection deterministic and lets re-tapping a tile
// act as a retry if a previous update was dropped.
updateChartData(chartType: chartType, category: selectedCategory, timeRange: selectedTimeRange)
FirebaseService.shared.logChartViewed(
chartType: chartType.rawValue,
isPremium: chartType.isPremium
)
}
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
@@ -11,7 +11,6 @@ class SettingsViewModel: ObservableObject {
@Published var isFamilyShared = false
@Published var notificationsEnabled = false
@Published var defaultNotificationTime = Date()
@Published var analyticsEnabled = true
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@Published var inputMode: InputMode = .simple
@@ -93,7 +92,6 @@ class SettingsViewModel: ObservableObject {
let settings = AppSettings.getOrCreate(in: context)
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
backupRetentionCount = loadBackupRetention()
@@ -107,8 +105,6 @@ class SettingsViewModel: ObservableObject {
// Load statistics directly from database to avoid async race conditions
loadStatistics()
FirebaseService.shared.logScreenView(screenName: "Settings")
}
private func loadStatistics() {
@@ -131,7 +127,6 @@ class SettingsViewModel: ObservableObject {
func upgradeToPremium() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
}
func restorePurchases() async {
@@ -141,10 +136,8 @@ class SettingsViewModel: ObservableObject {
if isPremium {
successMessage = "Purchases restored successfully!"
FirebaseService.shared.logRestorePurchases(success: true)
} else {
errorMessage = "No purchases to restore"
FirebaseService.shared.logRestorePurchases(success: false)
}
}
@@ -191,7 +184,6 @@ class SettingsViewModel: ObservableObject {
func exportData(format: ExportService.ExportFormat) {
guard freemiumValidator.canExport() else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "export")
return
}
@@ -242,13 +234,11 @@ class SettingsViewModel: ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.shareItem = ShareItem(url: tempURL)
}
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
}
} catch {
await MainActor.run {
isExporting = false
errorMessage = "Export failed: \(error.localizedDescription)"
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
}
}
}
@@ -272,19 +262,6 @@ class SettingsViewModel: ObservableObject {
freemiumValidator.canExport()
}
// MARK: - Analytics
func toggleAnalytics(_ enabled: Bool) {
analyticsEnabled = enabled
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.enableAnalytics = enabled
CoreDataStack.shared.save()
// Note: In production, you'd also update Firebase Analytics consent
}
// MARK: - Currency
func updateCurrency(_ code: String) {
@@ -555,7 +532,6 @@ class SettingsViewModel: ObservableObject {
guard isPremium else {
backupsEnabled = false
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
FirebaseService.shared.logPaywallShown(trigger: "backups")
showingPaywall = true
return
}
@@ -87,8 +87,6 @@ class SourceDetailViewModel: ObservableObject {
isLoading = true
refreshData()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
}
func refreshData() {
@@ -167,9 +165,6 @@ class SourceDetailViewModel: ObservableObject {
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
showingAddSnapshot = false
refreshData()
}
@@ -216,9 +211,6 @@ class SourceDetailViewModel: ObservableObject {
// Cancel any pending notifications for this source
NotificationService.shared.cancelReminder(for: source)
// Log analytics before deletion
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
// Delete the source using the existing repository
sourceRepository.deleteSource(source)
}
@@ -226,14 +218,9 @@ class SourceDetailViewModel: ObservableObject {
// MARK: - Predictions
func showPredictions() {
if freemiumValidator.canViewPredictions() {
// Already loaded, just navigate
FirebaseService.shared.logPredictionViewed(
algorithm: predictionResult?.algorithm.rawValue ?? "unknown"
)
} else {
// Predictions are already loaded; free users get the paywall instead.
if !freemiumValidator.canViewPredictions() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "predictions")
}
}
@@ -76,8 +76,6 @@ class SourceListViewModel: ObservableObject {
sourceRepository.fetchSources()
categoryRepository.fetchCategories()
isLoading = false
FirebaseService.shared.logScreenView(screenName: "SourceList")
}
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
@@ -115,7 +113,6 @@ class SourceListViewModel: ObservableObject {
showingAddSource = true
} else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "source_limit")
}
}
@@ -137,26 +134,15 @@ class SourceListViewModel: ObservableObject {
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: sourceRepository.sourceCount
)
showingAddSource = false
}
func deleteSource(_ source: InvestmentSource) {
let categoryName = source.category?.name ?? "Unknown"
// Cancel notifications
NotificationService.shared.cancelReminder(for: source)
// Delete source
sourceRepository.deleteSource(source)
// Log analytics
FirebaseService.shared.logSourceDeleted(categoryName: categoryName)
}
func deleteSource(at offsets: IndexSet) {
@@ -96,9 +96,6 @@ struct ChartsContainerView: View {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
if viewModel.hiddenHistoryMonths > 0 {
@@ -581,7 +578,6 @@ struct ChartsContainerView: View {
/// 12-month window; tell them exactly how much is locked instead of hiding it.
private var lockedHistoryTeaser: some View {
Button {
FirebaseService.shared.logPaywallShown(trigger: "history_teaser")
viewModel.showingPaywall = true
} label: {
HStack(spacing: 8) {
@@ -611,9 +607,6 @@ struct ChartsContainerView: View {
chartTypeChipBar
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
iPhonePeriodControl
if hasActiveFilters {
@@ -862,7 +855,6 @@ struct ChartsContainerView: View {
/// back.
private func drillDown(to name: String) {
guard viewModel.isPremium else {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
return
}
@@ -916,7 +908,6 @@ struct ChartsContainerView: View {
contributions: viewModel.contributionsData,
isPremium: viewModel.isPremium,
onPremiumNeeded: {
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
viewModel.showingPaywall = true
}
)
@@ -996,7 +987,6 @@ struct ChartsContainerView: View {
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
viewModel.showingPaywall = true
} label: {
Text(String(localized: "chart_locked_cta"))
@@ -113,9 +113,6 @@ struct DashboardView: View {
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
// Screen view logged here (once per appearance) NOT inside
// refreshData(), which re-runs on every Core Data change.
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
// Performance: Combine account selection changes into a single handler
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -218,7 +218,6 @@ struct EvolutionChartCard: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
onRequestUpgrade()
} label: {
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
@@ -96,7 +96,6 @@ struct OnboardingView: View {
}
Button {
FirebaseService.shared.logOnboardingSkipped(atStep: currentPage)
completeOnboarding()
} label: {
Text("Skip")
@@ -124,9 +123,6 @@ struct OnboardingView: View {
.onAppear {
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
.onChange(of: currentPage) { _, newPage in
FirebaseService.shared.logOnboardingStep(step: newPage)
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView(importContext: .onboarding)
.environmentObject(iapService)
@@ -157,9 +153,6 @@ struct OnboardingView: View {
SampleDataService.shared.seedSampleData()
}
// Log analytics
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
// Request notification permission
Task {
await NotificationService.shared.requestAuthorization()
@@ -336,9 +336,6 @@ struct PaywallView: View {
private func purchase() {
isPurchasing = true
let product = (selectedPlan == .annual ? iapService.annualProduct : iapService.premiumProduct)
FirebaseService.shared.logPurchaseAttempt(
productId: product?.id ?? IAPService.premiumProductID
)
Task {
do {
@@ -5,7 +5,6 @@ import UIKit
struct SettingsView: View {
private let iapService: IAPService
@StateObject private var viewModel: SettingsViewModel
@EnvironmentObject private var adMobService: AdMobService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
@AppStorage("showForecast") private var showForecast = true
@@ -776,14 +775,6 @@ struct SettingsView: View {
.onChange(of: viewModel.inputMode) { _, newValue in
viewModel.updateInputMode(newValue)
}
if !viewModel.isPremium {
Button("Manage Ad Consent") {
Task {
await adMobService.presentPrivacyOptions()
}
}
}
} header: {
Text("Preferences")
} footer: {
@@ -183,12 +183,6 @@ struct AddSourceView: View {
date: Date(),
value: value
)
// Instrument like AddSnapshotView does without this, snapshots
// created via the initial value never count in the activation funnel.
FirebaseService.shared.logSnapshotAdded(
sourceName: name.trimmingCharacters(in: .whitespaces),
value: value
)
hasInitialSnapshot = true
} else {
hasInitialSnapshot = false
@@ -203,12 +197,6 @@ struct AddSourceView: View {
let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue }
NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
sourceCount: repository.sourceCount
)
if hasInitialSnapshot {
dismiss()
} else {
@@ -641,9 +629,6 @@ struct AddSnapshotView: View {
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
// When editing, offer to propagate a changed contribution to other snapshots.
if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged {
pendingContribution = contribution
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""
Crashlytics report for Portfolio Journal via the BigQuery export.
Setup (one-time, already done 2026-07-13):
- Firebase console project portfoliojournal-ef2d7 Integrations BigQuery:
enable the Crashlytics export (requires Blaze plan).
- Grant the service account sirius@sirius-vazquez.iam BigQuery Data Viewer +
Job User on project portfoliojournal-ef2d7.
Creds come from `pass show google/sirius-service-account`. The export table is
created on the first crash after enabling, named like
`firebase_crashlytics.com_alexandrevazquez_PortfolioJournal_IOS`.
Usage: python3 Scripts/analyze_crashlytics.py [days] # default 30
"""
import subprocess, json, sys, urllib.request, urllib.error
PROJECT = "portfoliojournal-ef2d7"
DATASET = "firebase_crashlytics"
DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
def creds_token():
raw = subprocess.run(["pass", "show", "google/sirius-service-account"],
capture_output=True, text=True).stdout
try:
info = json.loads(raw)
except Exception:
out, ins, esc = [], False, False
for c in raw:
if esc: out.append(c); esc = False
elif c == "\\": out.append(c); esc = True
elif c == '"': ins = not ins; out.append(c)
elif ins and c == "\n": out.append("\\n")
elif ins and c == "\r": pass
else: out.append(c)
info = json.loads("".join(out))
from google.oauth2 import service_account
import google.auth.transport.requests
c = service_account.Credentials.from_service_account_info(
info, scopes=["https://www.googleapis.com/auth/cloud-platform"])
c.refresh(google.auth.transport.requests.Request())
return c.token
def api(token, path):
req = urllib.request.Request(
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}{path}",
headers={"Authorization": f"Bearer {token}"})
return json.load(urllib.request.urlopen(req))
def query(token, sql):
body = json.dumps({"query": sql, "useLegacySql": False, "timeoutMs": 60000}).encode()
req = urllib.request.Request(
f"https://bigquery.googleapis.com/bigquery/v2/projects/{PROJECT}/queries",
data=body, headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"})
d = json.load(urllib.request.urlopen(req))
cols = [f["name"] for f in d.get("schema", {}).get("fields", [])]
rows = [[c.get("v") for c in r["f"]] for r in d.get("rows", [])]
return cols, rows
def main():
token = creds_token()
try:
td = api(token, f"/datasets/{DATASET}/tables?maxResults=100")
except urllib.error.HTTPError as e:
print(f"No access to {DATASET} (HTTP {e.code}).")
return
tables = [t["tableReference"]["tableId"] for t in td.get("tables", [])]
crash_tables = [t for t in tables if "IOS" in t or "ios" in t.lower()]
if not crash_tables:
print("No Crashlytics export tables yet — no crashes exported since the "
"export was enabled. (Good news.)")
return
table = crash_tables[0]
print(f"== Crashlytics {table} — últimos {DAYS}d ==\n")
sql = f"""
SELECT
issue_title, issue_subtitle,
application.display_version AS version,
COUNT(*) AS events,
COUNT(DISTINCT installation_uuid) AS users,
COUNTIF(error_type = 'FATAL') AS fatal
FROM `{PROJECT}.{DATASET}.{table}`
WHERE event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {DAYS} DAY)
GROUP BY issue_title, issue_subtitle, version
ORDER BY events DESC
LIMIT 25
"""
cols, rows = query(token, sql)
if not rows:
print("Sin crashes en el periodo.")
return
for r in rows:
title, sub, ver, events, users, fatal = r
print(f"[{ver}] {title}{sub}")
print(f" {events} eventos · {users} usuarios · {fatal} fatales\n")
if __name__ == "__main__":
main()
-185
View File
@@ -1,185 +0,0 @@
"""
GA4 Analytics report for Portfolio Journal
Property: 300195945
"""
import os
from datetime import date, timedelta
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, Dimension, Metric, DateRange, OrderBy, FilterExpression, Filter
)
PROPERTY_ID = "519342587"
import subprocess, json, tempfile
def _fix_multiline_json(raw):
result, in_string, escape_next = [], False, False
for c in raw:
if escape_next:
result.append(c); escape_next = False
elif c == '\\':
result.append(c); escape_next = True
elif c == '"':
in_string = not in_string; result.append(c)
elif in_string and c == '\n':
result.append('\\n')
elif in_string and c == '\r':
pass
else:
result.append(c)
return ''.join(result)
key_raw = subprocess.check_output(["pass", "show", "google/sirius-service-account"]).decode()
key_json = json.dumps(json.loads(_fix_multiline_json(key_raw)))
_tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
_tmp.write(key_json); _tmp.flush()
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = _tmp.name
client = BetaAnalyticsDataClient()
TODAY = date.today().isoformat()
D7 = (date.today() - timedelta(days=7)).isoformat()
D28 = (date.today() - timedelta(days=28)).isoformat()
D90 = (date.today() - timedelta(days=90)).isoformat()
def run(dimensions, metrics, date_from, date_to=TODAY, order_by=None, limit=20):
req = RunReportRequest(
property=f"properties/{PROPERTY_ID}",
dimensions=[Dimension(name=d) for d in dimensions],
metrics=[Metric(name=m) for m in metrics],
date_ranges=[DateRange(start_date=date_from, end_date=date_to)],
limit=limit,
)
if order_by:
req.order_bys = order_by
return client.run_report(req)
def run_no_dim(metrics, date_from, date_to=TODAY):
req = RunReportRequest(
property=f"properties/{PROPERTY_ID}",
metrics=[Metric(name=m) for m in metrics],
date_ranges=[DateRange(start_date=date_from, end_date=date_to)],
)
return client.run_report(req)
def header(title):
print(f"\n{'='*60}")
print(f" {title}")
print('='*60)
def fmt_row(row, widths):
parts = [str(v.value).ljust(w) for v, w in zip(list(row.dimension_values) + list(row.metric_values), widths)]
print(" " + " ".join(parts))
# ── 1. Overview ────────────────────────────────────────────────
header("OVERVIEW — últimos 28 días vs 7 días")
r28 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D28)
r7 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D7)
def ovw(r):
row = r.rows[0]
vals = [v.value for v in row.metric_values]
return vals
v28 = ovw(r28)
v7 = ovw(r7)
labels = ["Usuarios activos", "Sesiones", "Pantallas vistas", "Duración media (s)"]
print(f"\n {'Métrica':<30} {'28d':>10} {'7d':>10}")
print(" " + "-"*52)
for l, a, b in zip(labels, v28, v7):
print(f" {l:<30} {a:>10} {b:>10}")
# ── 2. Pantallas más visitadas ──────────────────────────────────
header("PANTALLAS MÁS VISITADAS — últimos 28d")
r = run(["unifiedScreenName"], ["screenPageViews", "activeUsers"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"), desc=True)])
print(f"\n {'Pantalla':<40} {'Vistas':>8} {'Usuarios':>10}")
print(" " + "-"*60)
for row in r.rows:
fmt_row(row, [40, 8, 10])
# ── 3. Eventos clave ────────────────────────────────────────────
header("EVENTOS CLAVE — últimos 28d")
r = run(["eventName"], ["eventCount", "totalUsers"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
limit=30)
print(f"\n {'Evento':<45} {'Count':>8} {'Usuarios':>10}")
print(" " + "-"*65)
for row in r.rows:
fmt_row(row, [45, 8, 10])
# ── 4. Funnel paywall ───────────────────────────────────────────
header("FUNNEL PAYWALL — últimos 90d")
paywall_events = [
"paywall_shown", "purchase_attempt", "purchase_success", "purchase_failure",
"restore_purchases"
]
r = run(["eventName"], ["eventCount", "totalUsers"], D90,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
limit=50)
print(f"\n {'Evento paywall':<40} {'Count':>8} {'Usuarios':>10}")
print(" " + "-"*60)
for row in r.rows:
name = row.dimension_values[0].value
if any(p in name for p in ["paywall", "purchase", "restore", "premium"]):
fmt_row(row, [40, 8, 10])
# ── 5. Triggers del paywall ─────────────────────────────────────
header("TRIGGERS DEL PAYWALL — últimos 90d (qué feature lo dispara)")
try:
r = run(["eventName", "customEvent:paywall_trigger"], ["eventCount"], D90,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)],
limit=30)
rows = [row for row in r.rows if "paywall" in row.dimension_values[0].value]
if rows:
print(f"\n {'Evento':<30} {'Trigger':<25} {'Count':>8}")
print(" " + "-"*65)
for row in rows:
fmt_row(row, [30, 25, 8])
else:
print("\n (sin eventos paywall con trigger en este periodo — los datos aparecerán desde hoy)")
except Exception as e:
print(f"\n Error: {e}")
# ── 6. Retención — nuevos vs recurrentes ───────────────────────
header("NUEVOS vs RECURRENTES — últimos 28d")
r = run(["newVsReturning"], ["activeUsers", "sessions", "screenPageViews"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)])
print(f"\n {'Tipo':<20} {'Usuarios':>10} {'Sesiones':>10} {'Pantallas':>10}")
print(" " + "-"*54)
for row in r.rows:
fmt_row(row, [20, 10, 10, 10])
# ── 7. Versiones de app ─────────────────────────────────────────
header("VERSIONES EN USO — últimos 28d")
r = run(["appVersion"], ["activeUsers", "sessions"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
limit=10)
print(f"\n {'Versión':<20} {'Usuarios':>10} {'Sesiones':>10}")
print(" " + "-"*44)
for row in r.rows:
fmt_row(row, [20, 10, 10])
# ── 8. Dispositivos ─────────────────────────────────────────────
header("DISPOSITIVOS — últimos 28d")
r = run(["deviceModel"], ["activeUsers"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
limit=10)
print(f"\n {'Dispositivo':<30} {'Usuarios':>10}")
print(" " + "-"*44)
for row in r.rows:
fmt_row(row, [30, 10])
# ── 9. País / región ────────────────────────────────────────────
header("PAÍSES — últimos 28d")
r = run(["country"], ["activeUsers"], D28,
order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)],
limit=10)
print(f"\n {'País':<30} {'Usuarios':>10}")
print(" " + "-"*44)
for row in r.rows:
fmt_row(row, [30, 10])
print("\n")