Release 1.3.1 (build 20): iCloud sync fix + sources search/filter

iCloud sync:
- Fix: deploy CloudKit schema to production so exports work
- Fix: PredictionCache marked syncable=NO (binary attr caused partial failures)
- Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history
- Fix: auto-deduplication on CloudKit import (processRemoteChanges)
- Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state
- Add: cloudKitForceReload notification so repositories re-fetch on remote changes

Sources UI:
- Add: horizontal category filter chips in SourceListView
- Add: search toggle button with animated TextField

Other:
- Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance)
- Add: fastlane submit lane with run_precheck_before_submit=false
- Update: release notes all locales for 1.3.1
- Update: fastlane API key via pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-07 22:19:33 +02:00
parent 8acac3d529
commit 773da6800b
23 changed files with 524 additions and 144 deletions
@@ -75,7 +75,7 @@
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+163 -25
View File
@@ -3,6 +3,10 @@ import CloudKit
import Combine
import WidgetKit
extension Notification.Name {
static let cloudKitForceReload = Notification.Name("cloudKitForceReload")
}
class CoreDataStack: ObservableObject {
static let shared = CoreDataStack()
@@ -115,6 +119,35 @@ class CoreDataStack: ObservableObject {
}
@Published private(set) var isLoaded = false
@Published private(set) var lastImportDate: Date?
@Published private(set) var lastExportDate: Date?
@Published private(set) var isSyncing = false
@Published private(set) var lastSyncError: String?
var lastSyncDate: Date? { lastImportDate }
var localSourceCount: Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
return (try? viewContext.count(for: request)) ?? 0
}
var localSnapshotCount: Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
return (try? viewContext.count(for: request)) ?? 0
}
private init() {
// Register CloudKit event observer BEFORE the container is created so we
// never miss an import/export event that fires during store loading.
if Self.cloudKitEnabled {
NotificationCenter.default.addObserver(
self,
selector: #selector(cloudKitEventChanged(_:)),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil
)
}
}
lazy var persistentContainer: NSPersistentContainer = {
let container: NSPersistentContainer
@@ -131,12 +164,15 @@ class CoreDataStack: ObservableObject {
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
// Always enable history tracking so data created before CloudKit was enabled
// is visible to NSPersistentCloudKitContainer when sync is later turned on.
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
if Self.cloudKitEnabled {
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
container.persistentStoreDescriptions = [description]
@@ -177,42 +213,44 @@ class CoreDataStack: ObservableObject {
return persistentContainer.viewContext
}
private init() {}
// MARK: - Cleanup Duplicates
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
/// This fixes data corruption from race conditions during object creation.
func cleanupDuplicateObjects() {
/// This fixes data corruption from race conditions during object creation, and also
/// handles the case where a CloudKit first-time sync imports records that already exist
/// locally (because they were created before CloudKit was enabled).
@discardableResult
func cleanupDuplicateObjects() -> Int {
var totalRemoved = 0
let context = viewContext
context.performAndWait {
// Clean up duplicate Goals
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
// Clean up duplicate InvestmentSources
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
// Clean up duplicate Categories
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
// Clean up Snapshots first before cascade rules from InvestmentSource fire,
// so we deduplicate by UUID and not rely solely on cascade.
totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
if context.hasChanges {
try? context.save()
}
}
return totalRemoved
}
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
@discardableResult
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
guard let objects = try? context.fetch(request) else { return }
guard let objects = try? context.fetch(request) else { return 0 }
var seenIds = Set<UUID>()
var objectsToDelete: [NSManagedObject] = []
for object in objects {
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
if seenIds.contains(objectId) {
objectsToDelete.append(object)
} else {
@@ -221,11 +259,10 @@ class CoreDataStack: ObservableObject {
}
if !objectsToDelete.isEmpty {
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
for object in objectsToDelete {
context.delete(object)
}
print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects")
for object in objectsToDelete { context.delete(object) }
}
return objectsToDelete.count
}
// MARK: - Save Context
@@ -260,17 +297,118 @@ class CoreDataStack: ObservableObject {
@objc private func processRemoteChanges(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Force viewContext to re-read all objects from the persistent store,
// which now contains the changes CloudKit just synced from other devices.
// This triggers NSManagedObjectContextObjectsDidChange so all repositories
// automatically re-fetch and push fresh data to ViewModels.
// Force viewContext to re-read all objects from the persistent store.
self.viewContext.refreshAllObjects()
// Remove any duplicates that CloudKit import may have introduced.
// This handles the first-time sync case where records existed locally before
// CloudKit was enabled: the initial export+import creates duplicate objects.
let removed = self.cleanupDuplicateObjects()
if removed > 0 {
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
}
// Notify repositories to re-fetch unconditionally.
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
self.objectWillChange.send()
self.save()
self.refreshWidgetData()
}
}
@objc private func cloudKitEventChanged(_ notification: Notification) {
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let isActive = event.endDate == nil
self.isSyncing = isActive
if !isActive {
if event.succeeded, let endDate = event.endDate {
self.lastSyncError = nil
switch event.type {
case .import: self.lastImportDate = endDate
case .export: self.lastExportDate = endDate
default: break
}
} else if let error = event.error {
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
}
}
}
}
private static func describeError(_ error: Error, depth: Int = 0) -> String {
guard depth < 3 else { return "" }
let ns = error as NSError
var parts: [String] = ["\(ns.domain)(\(ns.code))"]
// At depth 0, always include the localised description so the user sees
// a human-readable message even when userInfo has no other keys.
if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String {
parts.append(msg)
}
for (key, value) in ns.userInfo {
let k = "\(key)"
if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue }
if let nestedError = value as? Error {
parts.append("\(k):\(describeError(nestedError, depth: depth + 1))")
} else if let dict = value as? [AnyHashable: Any], !dict.isEmpty {
let pairs = dict.prefix(3).map { kk, vv -> String in
if let e = vv as? Error { return "\(kk)\(describeError(e, depth: depth + 1))" }
return "\(kk)=\(vv)"
}
parts.append("\(k){\(pairs.joined(separator: ", "))}")
} else if !(value is [AnyHashable: Any]) {
parts.append("\(k)=\(value)")
}
}
return parts.joined(separator: "\n")
}
func forceReload() {
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
}
}
}
/// Forces NSPersistentCloudKitContainer to export all local data to iCloud.
///
/// Strategy: advance `createdAt` by 1 ms for every record. This guarantees
/// a *real* value change that the SQLite persistent store will write as a
/// persistent-history transaction which is what NSPersistentCloudKitContainer
/// needs to discover records and enqueue them for CloudKit export.
///
/// Setting a property to the *same* value may be silently discarded by the
/// SQLite layer (no SQL UPDATE issued no history entry nothing to export).
func forceExportToiCloud(completion: @escaping (Int) -> Void) {
guard Self.cloudKitEnabled else { completion(0); return }
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
let context = newBackgroundContext()
context.perform { [weak self] in
var totalTouched = 0
for entityName in entities {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.fetchBatchSize = 50
guard let objects = try? context.fetch(request) else { continue }
for object in objects {
// Advance createdAt by 1 ms always a genuine value change.
let t = (object.value(forKey: "createdAt") as? Date) ?? Date()
object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt")
totalTouched += 1
}
}
if context.hasChanges {
try? context.save()
}
DispatchQueue.main.async {
completion(totalTouched)
}
}
}
/// Forces an immediate re-read of all Core Data objects from the persistent store.
/// Call this when the app returns to the foreground so any iCloud changes made on
/// other devices (while this device was inactive) are picked up right away.
@@ -23,6 +23,11 @@ class AccountRepository: ObservableObject {
self?.fetchAccounts()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchAccounts() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -25,6 +25,11 @@ class CategoryRepository: ObservableObject {
self.fetchCategories()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchCategories() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -23,6 +23,11 @@ class GoalRepository: ObservableObject {
self?.fetchGoals()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchGoals() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -24,6 +24,11 @@ class InvestmentSourceRepository: ObservableObject {
self.fetchSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchSources() }
.store(in: &cancellables)
}
// MARK: - Fetch
+2
View File
@@ -35,6 +35,8 @@
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
@@ -293,6 +293,25 @@
"onboarding_add_first_source" = "Add My First Investment";
"onboarding_import_data" = "Import Existing Data";
// MARK: - iCloud Sync Status (1.3.1)
"Force Upload to iCloud" = "Force Upload to iCloud";
"Uploading..." = "Uploading...";
"No export yet" = "No export yet";
"Syncing with iCloud..." = "Syncing with iCloud...";
"Not synced yet" = "Not synced yet";
"Refresh" = "Refresh";
// MARK: - iCloud Diagnostics (1.3.1)
"Verify iCloud Setup" = "Verify iCloud Setup";
"Checking…" = "Checking…";
"context.hasChanges: YES ✓" = "context.hasChanges: YES ✓";
"context.hasChanges: NO ✗ — objects may not have been queued" = "context.hasChanges: NO ✗ — objects may not have been queued";
// MARK: - Sources Filter & Search (1.3.1)
"sources_filter_all" = "All";
"Search sources" = "Search sources";
"Clear Filters" = "Clear Filters";
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
@@ -222,6 +222,23 @@
"onboarding_add_first_source" = "Añadir mi primera inversión";
"onboarding_import_data" = "Importar datos existentes";
// MARK: - iCloud Sync Status (1.3.1)
"Force Upload to iCloud" = "Forzar subida a iCloud";
"Uploading..." = "Subiendo...";
"No export yet" = "Aún no exportado";
"Syncing with iCloud..." = "Sincronizando con iCloud...";
"Not synced yet" = "Aún no sincronizado";
"Refresh" = "Actualizar";
// MARK: - iCloud Diagnostics (1.3.1)
"Verify iCloud Setup" = "Verificar configuración iCloud";
"Checking…" = "Comprobando…";
// MARK: - Sources Filter & Search (1.3.1)
"sources_filter_all" = "Todas";
"Search sources" = "Buscar fuentes";
"Clear Filters" = "Limpiar filtros";
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
@@ -14,6 +14,8 @@ struct SettingsView: View {
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@ObservedObject private var cloudStack = CoreDataStack.shared
@State private var showingPinSetup = false
@State private var showingPinChange = false
@State private var showingBiometricAlert = false
@@ -22,6 +24,8 @@ struct SettingsView: View {
@State private var showingPinVerifyForFaceId = false
@State private var showingRestartAlert = false
@State private var didLoadCloudSync = false
@State private var isForceUploading = false
@State private var forceUploadResult: String?
@State private var backupToRestore: BackupRecord?
init(iapService: IAPService) {
@@ -342,6 +346,104 @@ struct SettingsView: View {
viewModel.refreshBackups()
}
if cloudSyncEnabled {
VStack(alignment: .leading, spacing: 6) {
// Local data counts
HStack {
Label(
"\(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots",
systemImage: "internaldrive"
)
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Button("Refresh") {
CoreDataStack.shared.forceReload()
}
.font(.subheadline)
.disabled(cloudStack.isSyncing)
}
// Import / export status
if cloudStack.isSyncing {
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
.font(.caption)
.foregroundColor(.secondary)
} else {
HStack(spacing: 12) {
if let date = cloudStack.lastImportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("No import yet", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.orange)
}
if let date = cloudStack.lastExportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.secondary)
} else {
Label("No export yet", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.orange)
}
}
}
// CloudKit error (if any)
if let error = cloudStack.lastSyncError {
VStack(alignment: .leading, spacing: 2) {
Label("CloudKit error", systemImage: "exclamationmark.icloud")
.font(.caption.weight(.semibold))
.foregroundColor(.red)
Text(error)
.font(.caption2)
.foregroundColor(.red)
.lineLimit(6)
.textSelection(.enabled)
}
}
// Force upload use when data exists locally but hasn't reached iCloud
if cloudStack.localSourceCount > 0 {
if let result = forceUploadResult {
Label(result, systemImage: "checkmark.icloud")
.font(.caption)
.foregroundColor(.secondary)
} else {
Button {
isForceUploading = true
CoreDataStack.shared.forceExportToiCloud { count in
isForceUploading = false
forceUploadResult = String(
format: NSLocalizedString("save_n_snapshots", comment: ""),
count
)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
forceUploadResult = nil
}
}
} label: {
if isForceUploading {
Label("Uploading...", systemImage: "arrow.triangle.2.circlepath.icloud")
} else {
Label("Force Upload to iCloud", systemImage: "icloud.and.arrow.up")
}
}
.font(.caption)
.foregroundColor(.appPrimary)
.disabled(isForceUploading)
}
}
}
.padding(.vertical, 2)
}
Toggle(
isOn: Binding(
get: { viewModel.backupsEnabled },
@@ -9,6 +9,7 @@ struct SourceListView: View {
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@State private var sourceToDelete: InvestmentSource?
@State private var navigationPath = NavigationPath()
@State private var showingSearch = false
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
@@ -24,11 +25,13 @@ struct SourceListView: View {
emptyStateView
} else {
sourcesList
.safeAreaInset(edge: .top, spacing: 0) {
filterBar
}
}
}
}
.navigationTitle("Sources")
.searchable(text: $viewModel.searchText, prompt: "Search sources")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
@@ -38,8 +41,16 @@ struct SourceListView: View {
}
}
ToolbarItem(placement: .navigationBarLeading) {
categoryFilterMenu
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
showingSearch.toggle()
}
if !showingSearch { viewModel.searchText = "" }
} label: {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.foregroundColor(showingSearch ? .secondary : .primary)
}
}
ToolbarItem(placement: .navigationBarLeading) {
@@ -213,46 +224,64 @@ struct SourceListView: View {
}
}
// MARK: - Category Filter Menu
// MARK: - Filter Bar (chips + search)
private var categoryFilterMenu: some View {
Menu {
Button {
viewModel.selectCategory(nil)
} label: {
HStack {
Text("All Categories")
if viewModel.selectedCategory == nil {
Image(systemName: "checkmark")
private var filterBar: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
CategoryChip(
title: String(localized: "sources_filter_all"),
icon: nil,
isSelected: viewModel.selectedCategory == nil
) {
viewModel.selectCategory(nil)
}
}
}
Divider()
ForEach(viewModel.categories) { category in
Button {
viewModel.selectCategory(category)
} label: {
HStack {
Image(systemName: category.icon)
Text(category.name)
if viewModel.selectedCategory?.id == category.id {
Image(systemName: "checkmark")
ForEach(viewModel.categories) { category in
CategoryChip(
title: category.name,
icon: category.icon,
isSelected: viewModel.selectedCategory?.id == category.id
) {
viewModel.selectCategory(category)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
} label: {
HStack(spacing: 4) {
Image(systemName: "line.3.horizontal.decrease.circle")
if viewModel.selectedCategory != nil {
Circle()
.fill(Color.appPrimary)
.frame(width: 8, height: 8)
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search sources", text: $viewModel.searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(Color(.systemGray6))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.bottom, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
Divider()
}
.background(.bar)
}
// MARK: - Account Filter Menu
@@ -301,6 +330,35 @@ struct SourceListView: View {
}
}
// MARK: - Category Chip
private struct CategoryChip: View {
let title: String
let icon: String?
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
if let icon {
Image(systemName: icon)
.font(.caption2)
}
Text(title)
.font(.subheadline)
.fontWeight(isSelected ? .semibold : .regular)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.appPrimary : Color(.systemGray5))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(20)
}
.buttonStyle(.plain)
}
}
private struct MissingSourceView: View {
var body: some View {
VStack(spacing: 12) {