From a0b3a3fe3ce7ca2206abd1959f4d3487c29c8857 Mon Sep 17 00:00:00 2001 From: Sebastian Ricaldoni Date: Tue, 9 Jun 2026 12:26:58 -0300 Subject: [PATCH 1/5] fix(BAL-211): improve game session duration logging on app background - Add finalizeOpenActions() to close pending "Opened X" actions when app backgrounds, fixing zero-duration bug when app is killed mid-game - Move reset() logic to UIApplication.willEnterForegroundNotification observer on ActivityLogEntry (synchronous, fires before SwiftUI views) to avoid race condition with async saveLog on background - Add didBecomeActiveNotification handler in ActivityLogBaseView with isVisible guard to re-log current view on resume when onAppear does not fire reliably - Add Home view logging via ActivityLogBaseView wrapper - Add Encodable.toJSON() debug utility in BalanceExtensions - Fix WKCompanionAppBundleIdentifier for local device signing --- Balance.xcodeproj/project.pbxproj | 35 ++++++----- .../ActivityLogging/ActivityLogBaseView.swift | 41 ++++++------ .../ActivityStorageManager.swift | 63 ++++++++++++++++--- Balance/Balance.swift | 22 +++++-- Balance/Home/Home.swift | 2 + Balance/Utils/BalanceExtensions.swift | 15 +++++ 6 files changed, 130 insertions(+), 48 deletions(-) diff --git a/Balance.xcodeproj/project.pbxproj b/Balance.xcodeproj/project.pbxproj index 820e368..057151b 100644 --- a/Balance.xcodeproj/project.pbxproj +++ b/Balance.xcodeproj/project.pbxproj @@ -1316,19 +1316,19 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = BalanceWatchApp/BalanceWatchApp.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 58MHXEFYLU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = BalanceWatchApp; - INFOPLIST_KEY_NSHealthShareUsageDescription = "$(PRODUCT_NAME) would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "$(PRODUCT_NAME) would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "BalanceWatchApp would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "BalanceWatchApp would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.cs342.2023.balance; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.celteeka.cs342.2023.balance; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1336,8 +1336,9 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.cs342.2023.balance.watchApp; + PRODUCT_BUNDLE_IDENTIFIER = com.celteeka.cs342.2023.balance.watchApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -1367,10 +1368,10 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = BalanceWatchApp; - INFOPLIST_KEY_NSHealthShareUsageDescription = "$(PRODUCT_NAME) would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "$(PRODUCT_NAME) would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "BalanceWatchApp would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "BalanceWatchApp would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.cs342.2023.balance; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.celteeka.cs342.2023.balance; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1516,11 +1517,11 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Balance/BalanceDebug.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 58MHXEFYLU; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -1535,9 +1536,10 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Balance/Supporting Files/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Balance; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; - INFOPLIST_KEY_NSHealthShareUsageDescription = "The CS342 2023 Balance Team Application uses the step count to demonstrate CardinalKit's integration with HealthKit."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The CS342 2023 Balance Team Application uses the step count to demonstrate CardinalKit's integration with HealthKit."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "$(PRODUCT_NAME) would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "$(PRODUCT_NAME) would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "This message should never appear. Please adjust this when you start using location information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This message should never appear. Please adjust this when you start using location information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "This message should never appear. Please adjust this when you start using microphone information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; @@ -1558,7 +1560,7 @@ ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "-ObjC"; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.cs342.2023.balance; + PRODUCT_BUNDLE_IDENTIFIER = com.celteeka.cs342.2023.balance; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1598,9 +1600,10 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Balance/Supporting Files/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Balance; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; - INFOPLIST_KEY_NSHealthShareUsageDescription = "The CS342 2023 Balance Team Application uses the step count to demonstrate CardinalKit's integration with HealthKit."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The CS342 2023 Balance Team Application uses the step count to demonstrate CardinalKit's integration with HealthKit."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "$(PRODUCT_NAME) would like to capture a snap shot of your heart rate for the purpose of using the data in a clinical research study. Please grant this permission to continue participating."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "$(PRODUCT_NAME) would like to update your health usage with heart rate data. Actually we never do anything of the kind but it seems Apple has lost control of X-Code and has few if any answers, so we will ask this permission."; INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "This message should never appear. Please adjust this when you start using location information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This message should never appear. Please adjust this when you start using location information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "This message should never appear. Please adjust this when you start using microphone information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; diff --git a/Balance/ActivityLogging/ActivityLogBaseView.swift b/Balance/ActivityLogging/ActivityLogBaseView.swift index 31f2fae..823d573 100644 --- a/Balance/ActivityLogging/ActivityLogBaseView.swift +++ b/Balance/ActivityLogging/ActivityLogBaseView.swift @@ -25,43 +25,46 @@ struct ActivityLogContainer: View where Content: View { struct ActivityLogBaseView: View where Content: View { @EnvironmentObject var activityLogEntry: ActivityLogEntry @EnvironmentObject var logStore: ActivityLogStore + @State private var isVisible = false private let viewName: String private let isDirectChildToContainer: Bool private let content: Content - + var body: some View { content - .onReceive(NotificationCenter.default.publisher(for: Notification.Name.goBackground)) { _ in - activityLogEntry.reset() - } .onAppear(perform: { - activityLogEntry.addAction(actionDescription: "Opened \(viewName)") #if DEBUG - print("Opened \(viewName)") + print("[ActivityLogBaseView][onAppear] - View: \(viewName)") #endif + isVisible = true + activityLogEntry.addAction(actionDescription: "Opened \(viewName)") }) .onDisappear(perform: { +#if DEBUG + print("[ActivityLogBaseView][onDisappear] - View: \(viewName)") +#endif + isVisible = false activityLogEntry.endLog(actionDescription: "Closed \(viewName)") - + if isDirectChildToContainer { -#if DEMO logStore.saveLog(activityLogEntry) -#else - ActivityStorageManager.shared.uploadActivity(activityLogEntry: activityLogEntry) -#endif - // for debugging - let activityLogEntryString = activityLogEntry.toString() + } + }) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in #if DEBUG - print("Sending activity log to storage manager: \(activityLogEntryString)") + print("[ActivityLogBaseView][didBecomeActive]: \(viewName)") #endif - } - + guard isVisible else { return } + let alreadyLogged = activityLogEntry.actions.contains { $0.description == "Opened \(viewName)" } + if !alreadyLogged { #if DEBUG - print("Closed \(viewName)") + print("[ActivityLogBaseView][didBecomeActive] - View is Visible and wasn't logged yet. Readding view: \(viewName)") #endif - }) + activityLogEntry.addAction(actionDescription: "Opened \(viewName)") + } + } } - + public init(viewName: String, isDirectChildToContainer: Bool = false, @ViewBuilder content: () -> Content) { self.viewName = viewName self.isDirectChildToContainer = isDirectChildToContainer diff --git a/Balance/ActivityLogging/ActivityStorageManager.swift b/Balance/ActivityLogging/ActivityStorageManager.swift index f14f2e2..939753b 100644 --- a/Balance/ActivityLogging/ActivityStorageManager.swift +++ b/Balance/ActivityLogging/ActivityStorageManager.swift @@ -22,7 +22,7 @@ import Foundation // swiftlint:disable all -struct LogAction { +struct LogAction: Codable { let sessionID: String let sessionStartTime: Date let sessionEndTime: Date @@ -55,7 +55,26 @@ class ActivityLogEntry: ObservableObject, Codable { var endTime = Date.now var duration: Int = 0 var actions: [Action] = [] - + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleWillEnterForeground() { + let spotifyWasActive = UserDefaults.standard.bool(forKey: StorageKeys.spotifyConnect) + if !spotifyWasActive { +#if DEBUG + print("[ActivityLogEntry][handleWillEnterForeground] - Resetting entry") +#endif + reset() + } + } + func reset() { id = UUID().uuidString startTime = Date() @@ -71,6 +90,10 @@ class ActivityLogEntry: ObservableObject, Codable { func addAction(actionDescription: String) { let currentDate = Date.now actions.append(Action(description: actionDescription, startTime: currentDate)) + print("[ActivityStorageManager][addAction] - Action count: \(actions.count)") + print("[ActivityStorageManager][addAction] - ------------------------------") + print("[ActivityStorageManager][addAction] - \(actions.toJSON())") + print("[ActivityStorageManager][addAction] - ------------------------------") } func addActionButton(actionDescription: String) { @@ -84,12 +107,36 @@ class ActivityLogEntry: ObservableObject, Codable { ) ) // set start time if this is the first action - startTime = currentDate - endTime = currentDate - - duration = 0 +// startTime = currentDate +// endTime = currentDate +// duration = 0 + print("[ActivityStorageManager][addActionButton] - Action count: \(actions.count)") + print("[ActivityStorageManager][addActionButton] - ------------------------------") + print("[ActivityStorageManager][addActionButton] - \(actions.toJSON())") + print("[ActivityStorageManager][addActionButton] - ------------------------------") + } + func finalizeOpenActions() { + print("[ActivityStorageManager][finalizeOpenActions] - Action count: \(actions.count)") + guard let lastAction = actions.last, lastAction.description.hasPrefix("Opened ") else { return } + print("[ActivityStorageManager][finalizeOpenActions] - Last action: { \(lastAction.toJSON()) }") + let now = Date.now + let interval = now - lastAction.startTime + actions.append( + Action( + description: lastAction.description.replacingOccurrences(of: "Opened ", with: ""), + startTime: lastAction.startTime, + endTime: now, + duration: interval.second ?? 0 + ) + ) + actions = actions.filter { $0.id != lastAction.id } + startTime = actions.first!.startTime + endTime = actions.last!.endTime + duration = ((endTime - startTime).second ?? 0) + } + func endLog(actionDescription: String) { let currentEndDate = Date.now let startAction = actions.last(where: { @@ -138,11 +185,12 @@ class ActivityLogEntry: ObservableObject, Codable { } } - print(interval.second ?? 0) + print("[ActiityLogEntry][endLog] - \(interval.second ?? 0)") startTime = actions.first!.startTime endTime = actions.last!.endTime let intervalSession = endTime - startTime duration = intervalSession.second ?? 0 + print("[endLog] - Action count: \(actions.count)") } func getDuration() -> TimeInterval { @@ -205,3 +253,4 @@ class ActivityStorageManager { } } } + diff --git a/Balance/Balance.swift b/Balance/Balance.swift index c1f78df..76153aa 100644 --- a/Balance/Balance.swift +++ b/Balance/Balance.swift @@ -87,14 +87,24 @@ struct Balance: App { func inactiveApp() { } func backgroundApp() { + print("[Balance][backgroundApp] - App going to BG") let value = UserDefaults.standard.bool(forKey: StorageKeys.spotifyConnect) if value == false { - activityLogEntry.reset() + activityLogEntry.finalizeOpenActions() + if !activityLogEntry.isEmpty() { +#if DEMO + print("[Balance::backgroundApp] - DEMO Mode ON. Saving values") + logStore.saveLog(activityLogEntry) +#else + ActivityStorageManager.shared.uploadActivity(activityLogEntry: activityLogEntry) +#endif + } } self.heartAlert = false } func activeApp() { + print("[Balance][activeApp] - App activating") let patientId = UserDefaults.standard.string(forKey: "lastPatient") ?? "" if patientId.isEmpty { if heartAlert == false { @@ -110,11 +120,11 @@ struct Balance: App { activityLogEntry.addActionButton(actionDescription: description) #if DEMO logStore.saveLog(activityLogEntry) - ActivityLogStore.save(logs: logStore.logs) { result in - if case .failure(let error) = result { - print(error.localizedDescription) - } - } +// ActivityLogStore.save(logs: logStore.logs) { result in +// if case .failure(let error) = result { +// print(error.localizedDescription) +// } +// } #else ActivityStorageManager.shared.uploadActivity(activityLogEntry: activityLogEntry) #endif diff --git a/Balance/Home/Home.swift b/Balance/Home/Home.swift index d2ca068..863f1d1 100644 --- a/Balance/Home/Home.swift +++ b/Balance/Home/Home.swift @@ -17,6 +17,7 @@ struct HomeView: View { var body: some View { ActivityLogContainer { + ActivityLogBaseView(viewName: "Home", isDirectChildToContainer: true) { ZStack { backgroundColor.edgesIgnoringSafeArea(.all) NavigationStack { @@ -51,6 +52,7 @@ struct HomeView: View { localNotification() } } + } } @ViewBuilder private var loadingOverlay: some View { diff --git a/Balance/Utils/BalanceExtensions.swift b/Balance/Utils/BalanceExtensions.swift index c2f3a82..6ac4d9c 100644 --- a/Balance/Utils/BalanceExtensions.swift +++ b/Balance/Utils/BalanceExtensions.swift @@ -166,3 +166,18 @@ extension Array { return Array(self[0.. String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(self), + let json = String(data: data, encoding: .utf8) else { + return "" + } + return json + } +} +#endif From 74e347e8628fed468727924fd05caa52c25801c9 Mon Sep 17 00:00:00 2001 From: Sebastian Ricaldoni Date: Tue, 9 Jun 2026 18:57:34 -0300 Subject: [PATCH 2/5] fix(BAL-211): refactor activity logging to stack-based push/finalize model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile "Opened X" / "Closed X" string-matching with a clean push(viewName:) + finalizePending() stack model: - push() finalizes previous pending entry then opens a new one - finalizePending() closes current entry with correct duration - onDisappear only finalizes if pendingEntry matches view (guards against forward-nav double-finalize since onAppear fires first) - ActivityLogEntry observes willEnterForegroundNotification directly (synchronous, before SwiftUI views) to guarantee reset fires before any onAppear on resume - ActivityLogBaseView uses didBecomeActiveNotification + isVisible guard to re-push current view when onAppear does not fire on resume - Home ActivityLogBaseView moved inside NavigationStack root so onAppear/onDisappear fire correctly on forward/back navigation - Rename actions → entries (CodingKeys maps to "actions" for backwards compatibility with existing user data) - addActionButton renamed to addButtonEvent for clarity - Coins eligibility extracted to Set replacing long if-chain - ProfileView share buttons do fresh file load on tap (single source of truth); ShareLink replaced with Button + UIActivityViewController - SpotifyViewController updated to new push/finalizePending API --- .../ActivityLogging/ActivityLogBaseView.swift | 31 +-- .../ActivityLogButtonStyle.swift | 12 +- .../ActivityStorageManager.swift | 216 ++++++------------ Balance/Balance.swift | 4 +- .../Music/iOS_SDK/SpotifyViewController.swift | 4 +- Balance/Home/Home.swift | 13 +- Balance/Profile/ProfileView.swift | 91 ++++---- 7 files changed, 151 insertions(+), 220 deletions(-) diff --git a/Balance/ActivityLogging/ActivityLogBaseView.swift b/Balance/ActivityLogging/ActivityLogBaseView.swift index 823d573..62fa34e 100644 --- a/Balance/ActivityLogging/ActivityLogBaseView.swift +++ b/Balance/ActivityLogging/ActivityLogBaseView.swift @@ -11,17 +11,16 @@ import SwiftUI struct ActivityLogContainer: View where Content: View { @EnvironmentObject var activityLogEntry: ActivityLogEntry private let content: Content - + var body: some View { content.environmentObject(activityLogEntry) } - + public init(@ViewBuilder content: () -> Content) { self.content = content() } } -// This base view implements functionality to log information that will be send to the ActivityStorageManager struct ActivityLogBaseView: View where Content: View { @EnvironmentObject var activityLogEntry: ActivityLogEntry @EnvironmentObject var logStore: ActivityLogStore @@ -32,35 +31,37 @@ struct ActivityLogBaseView: View where Content: View { var body: some View { content - .onAppear(perform: { + .onAppear { #if DEBUG print("[ActivityLogBaseView][onAppear] - View: \(viewName)") #endif isVisible = true - activityLogEntry.addAction(actionDescription: "Opened \(viewName)") - }) - .onDisappear(perform: { + activityLogEntry.push(viewName: viewName) + } + .onDisappear { #if DEBUG print("[ActivityLogBaseView][onDisappear] - View: \(viewName)") #endif isVisible = false - activityLogEntry.endLog(actionDescription: "Closed \(viewName)") - + // On forward nav, push() already finalized this view before onDisappear fires. + // Only finalize if pendingEntry still matches — i.e. this is a back navigation. + if activityLogEntry.pendingEntry?.description == viewName { + activityLogEntry.finalizePending() + } if isDirectChildToContainer { logStore.saveLog(activityLogEntry) } - }) + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in #if DEBUG - print("[ActivityLogBaseView][didBecomeActive]: \(viewName)") + print("[ActivityLogBaseView][didBecomeActive] - View: \(viewName), isVisible: \(isVisible)") #endif guard isVisible else { return } - let alreadyLogged = activityLogEntry.actions.contains { $0.description == "Opened \(viewName)" } - if !alreadyLogged { + if activityLogEntry.pendingEntry?.description != viewName { #if DEBUG - print("[ActivityLogBaseView][didBecomeActive] - View is Visible and wasn't logged yet. Readding view: \(viewName)") + print("[ActivityLogBaseView][didBecomeActive] - Re-pushing view: \(viewName)") #endif - activityLogEntry.addAction(actionDescription: "Opened \(viewName)") + activityLogEntry.push(viewName: viewName) } } } diff --git a/Balance/ActivityLogging/ActivityLogButtonStyle.swift b/Balance/ActivityLogging/ActivityLogButtonStyle.swift index 5480e25..845c625 100644 --- a/Balance/ActivityLogging/ActivityLogButtonStyle.swift +++ b/Balance/ActivityLogging/ActivityLogButtonStyle.swift @@ -19,14 +19,14 @@ struct ActivityLogButtonStyle: PrimitiveButtonStyle { configuration.label .onTapGesture { configuration.trigger() - activityLogEntry.addActionButton(actionDescription: "Button " + activityDescription) + activityLogEntry.addButtonEvent(description: "Button " + activityDescription) #if DEMO logStore.saveLog(activityLogEntry) - ActivityLogStore.save(logs: logStore.logs) { result in - if case .failure(let error) = result { - print(error.localizedDescription) - } - } +// ActivityLogStore.save(logs: logStore.logs) { result in +// if case .failure(let error) = result { +// print(error.localizedDescription) +// } +// } #else ActivityStorageManager.shared.uploadActivity(activityLogEntry: activityLogEntry) #endif diff --git a/Balance/ActivityLogging/ActivityStorageManager.swift b/Balance/ActivityLogging/ActivityStorageManager.swift index 939753b..4c35e56 100644 --- a/Balance/ActivityLogging/ActivityStorageManager.swift +++ b/Balance/ActivityLogging/ActivityStorageManager.swift @@ -3,23 +3,13 @@ // Balance // // Created by Alexis Lowber on 3/5/23. -// +// import FirebaseAuth import FirebaseCore import FirebaseFirestore import Foundation - -// Activity Log Structure: -/* - start timestamp (view appears) - action (button press) - action (button press) - end timestamp (view disappears) - total duration: - */ - // swiftlint:disable all struct LogAction: Codable { @@ -41,20 +31,31 @@ struct Action: Codable { var duration: Int = 0 } +private let coinsEligibleViews: Set = [ + "Image Highlight", "Image Selected", "Video Highlight", "Video Selected", + "Sudoku Game", "Crossover Game", "Tetris Game", "Simon Says Game", + "Bouncing Ball Game", "2048 Game", "Solitaire Game", "Guess the Emotion", + "How is your mood", "New Draw", "Mandala Selected", "Coloring Saved", + "Draw Saved", "Playing Spotify", "Body sensations Part", + "Breathing Feature", "Guided meditation Feature" +] + class ActivityLogEntry: ObservableObject, Codable { enum CodingKeys: String, CodingKey { case id case startTime case endTime case duration - case actions + case entries = "actions" + case pendingEntry } var id = UUID().uuidString var startTime = Date() var endTime = Date.now var duration: Int = 0 - var actions: [Action] = [] + var entries: [Action] = [] + var pendingEntry: Action? init() { NotificationCenter.default.addObserver( @@ -75,149 +76,88 @@ class ActivityLogEntry: ObservableObject, Codable { } } + // Push new view. Finalizes previous pending entry first. + func push(viewName: String) { +#if DEBUG + print("[ActivityLogEntry][push] - viewName: \(viewName), entries: \(entries.count + 1)") +#endif + let now = Date.now + finalizePending(at: now) + pendingEntry = Action(description: viewName, startTime: now) + if entries.isEmpty { + startTime = now + } + } + + // Finalize current pending entry (onDisappear or app background). + func finalizePending() { + finalizePending(at: Date.now) + } + + private func finalizePending(at endDate: Date) { + guard var pending = pendingEntry else { return } + let interval = endDate - pending.startTime + pending.endTime = endDate + pending.duration = interval.second ?? 0 + entries.append(pending) + pendingEntry = nil + + if coinsEligibleViews.contains(pending.description) && (pending.duration > coinsTime) { + NotificationCenter.default.post(name: Notification.Name.coinsUpdate, object: nil) + } + + if let first = entries.first { + startTime = first.startTime + } + endTime = endDate + duration = (endTime - startTime).second ?? 0 +#if DEBUG + print("[ActivityLogEntry][finalizePending] - Finalized: \(pending.description), duration: \(pending.duration)s") +#endif + } + + // Instantaneous button-press event (no duration). + func addButtonEvent(description: String) { + let now = Date.now + entries.append(Action(description: description, startTime: now, endTime: now, duration: 0)) +#if DEBUG + print("[ActivityLogEntry][addButtonEvent] - \(description)") +#endif + } + func reset() { id = UUID().uuidString startTime = Date() endTime = startTime duration = 0 - actions = [] + entries = [] + pendingEntry = nil } - + func isEmpty() -> Bool { - return getDuration() == 0 - } - - func addAction(actionDescription: String) { - let currentDate = Date.now - actions.append(Action(description: actionDescription, startTime: currentDate)) - print("[ActivityStorageManager][addAction] - Action count: \(actions.count)") - print("[ActivityStorageManager][addAction] - ------------------------------") - print("[ActivityStorageManager][addAction] - \(actions.toJSON())") - print("[ActivityStorageManager][addAction] - ------------------------------") - } - - func addActionButton(actionDescription: String) { - let currentDate = Date.now - actions.append( - Action( - description: actionDescription, - startTime: currentDate, - endTime: currentDate, - duration: 0 - ) - ) - // set start time if this is the first action -// startTime = currentDate -// endTime = currentDate -// duration = 0 - print("[ActivityStorageManager][addActionButton] - Action count: \(actions.count)") - print("[ActivityStorageManager][addActionButton] - ------------------------------") - print("[ActivityStorageManager][addActionButton] - \(actions.toJSON())") - print("[ActivityStorageManager][addActionButton] - ------------------------------") - - } - - func finalizeOpenActions() { - print("[ActivityStorageManager][finalizeOpenActions] - Action count: \(actions.count)") - guard let lastAction = actions.last, lastAction.description.hasPrefix("Opened ") else { return } - print("[ActivityStorageManager][finalizeOpenActions] - Last action: { \(lastAction.toJSON()) }") - let now = Date.now - let interval = now - lastAction.startTime - actions.append( - Action( - description: lastAction.description.replacingOccurrences(of: "Opened ", with: ""), - startTime: lastAction.startTime, - endTime: now, - duration: interval.second ?? 0 - ) - ) - actions = actions.filter { $0.id != lastAction.id } - startTime = actions.first!.startTime - endTime = actions.last!.endTime - duration = ((endTime - startTime).second ?? 0) + return entries.isEmpty } - func endLog(actionDescription: String) { - let currentEndDate = Date.now - let startAction = actions.last(where: { - $0.description == actionDescription.replacingOccurrences(of: "Closed", with: "Opened") - }) - - if startAction == nil { - return - } - let interval = currentEndDate - startAction!.startTime - actions.append( - Action( - description: actionDescription.replacingOccurrences(of: "Closed ", with: ""), - startTime: startAction!.startTime, - endTime: currentEndDate, - duration: interval.second ?? 0 - ) - ) - actions = actions.filter( { - $0.id != startAction?.id - }) - - if actionDescription.contains("Closed Image Highlight") || - actionDescription.contains("Closed Image Selected") || - actionDescription.contains("Closed Video Highlight") || - actionDescription.contains("Closed Video Selected") || - actionDescription.contains("Closed Sudoku Game") || - actionDescription.contains("Closed Crossover Game") || - actionDescription.contains("Closed Tetris Game") || - actionDescription.contains("Closed Simon Says Game") || - actionDescription.contains("Closed Bouncing Ball Game") || - actionDescription.contains("Closed 2048 Game") || - actionDescription.contains("Closed Solitaire Game") || - actionDescription.contains("Closed Guess the Emotion") || - actionDescription.contains("Closed How is your mood") || - actionDescription.contains("Closed New Draw") || - actionDescription.contains("Closed Mandala Selected") || - actionDescription.contains("Closed Coloring Saved") || - actionDescription.contains("Closed Draw Saved") || - actionDescription.contains("Closed Playing Spotify") || - actionDescription.contains("Closed Body sensations Part") || - actionDescription.contains("Closed Breathing Feature") || - actionDescription.contains("Closed Guided meditation Feature") { - if (interval.second ?? 0) > coinsTime { - NotificationCenter.default.post(name: Notification.Name.coinsUpdate, object: nil) - } - } - - print("[ActiityLogEntry][endLog] - \(interval.second ?? 0)") - startTime = actions.first!.startTime - endTime = actions.last!.endTime - let intervalSession = endTime - startTime - duration = intervalSession.second ?? 0 - print("[endLog] - Action count: \(actions.count)") - } - func getDuration() -> TimeInterval { return endTime.timeIntervalSinceReferenceDate - startTime.timeIntervalSinceReferenceDate } - + func toString() -> (String, String) { let idStr = dateToString(date: startTime) - let startStr = "start: " + idStr let endStr = endTime != Date(timeIntervalSinceReferenceDate: 0) ? "end: " + dateToString(date: endTime) : "" let durationStr = "duration: \(duration)" - var actionsStr = "" - - for action in actions { - actionsStr.append("\(dateToString(date: action.startTime) + " " + action.description)\n") + for entry in entries { + actionsStr.append("\(dateToString(date: entry.startTime) + " " + entry.description)\n") } - return (idStr, [startStr, actionsStr, durationStr, endStr].joined(separator: "\n")) } - + func dateToString(date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" formatter.timeZone = .current - return formatter.string(from: date) } } @@ -225,15 +165,13 @@ class ActivityLogEntry: ObservableObject, Codable { class ActivityStorageManager { static let shared = ActivityStorageManager() - + func uploadActivity(activityLogEntry: ActivityLogEntry) { guard !activityLogEntry.isEmpty() else { - // TODO: switch to logging statement print("Cannot send activity data without both start and end time fields") return } - - // Authorize User: users should be signed in to use the app + guard let user = Auth.auth().currentUser else { #if DEBUG print("Error finding current user (FIRUser)") @@ -241,16 +179,14 @@ class ActivityStorageManager { return } let userID = user.uid - + let database = Firestore.firestore() - let startID = "\(activityLogEntry.dateToString(date: activityLogEntry.startTime))" - + do { try database.collection("users").document("\(userID)/activity/\(startID).txt").setData(from: activityLogEntry) } catch let error { - print("Error writing city to Firestore: \(error)") + print("Error writing activity to Firestore: \(error)") } } } - diff --git a/Balance/Balance.swift b/Balance/Balance.swift index 76153aa..d5b6e1a 100644 --- a/Balance/Balance.swift +++ b/Balance/Balance.swift @@ -90,7 +90,7 @@ struct Balance: App { print("[Balance][backgroundApp] - App going to BG") let value = UserDefaults.standard.bool(forKey: StorageKeys.spotifyConnect) if value == false { - activityLogEntry.finalizeOpenActions() + activityLogEntry.finalizePending() if !activityLogEntry.isEmpty() { #if DEMO print("[Balance::backgroundApp] - DEMO Mode ON. Saving values") @@ -117,7 +117,7 @@ struct Balance: App { } func appEvent(description: String) { - activityLogEntry.addActionButton(actionDescription: description) + activityLogEntry.addButtonEvent(description: description) #if DEMO logStore.saveLog(activityLogEntry) // ActivityLogStore.save(logs: logStore.logs) { result in diff --git a/Balance/Distraction/Music/iOS_SDK/SpotifyViewController.swift b/Balance/Distraction/Music/iOS_SDK/SpotifyViewController.swift index 052b663..4506a44 100644 --- a/Balance/Distraction/Music/iOS_SDK/SpotifyViewController.swift +++ b/Balance/Distraction/Music/iOS_SDK/SpotifyViewController.swift @@ -110,7 +110,7 @@ class SpotifyViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - activityLogEntry?.addAction(actionDescription: "Opened Playing Spotify") + activityLogEntry?.push(viewName: "Playing Spotify") updateViewBasedOnConnected() } @@ -121,7 +121,7 @@ class SpotifyViewController: UIViewController { appRemote.playerAPI?.pause(nil) } } - activityLogEntry?.endLog(actionDescription: "Closed Playing Spotify") + activityLogEntry?.finalizePending() } @objc diff --git a/Balance/Home/Home.swift b/Balance/Home/Home.swift index 863f1d1..765f53d 100644 --- a/Balance/Home/Home.swift +++ b/Balance/Home/Home.swift @@ -17,14 +17,15 @@ struct HomeView: View { var body: some View { ActivityLogContainer { - ActivityLogBaseView(viewName: "Home", isDirectChildToContainer: true) { ZStack { backgroundColor.edgesIgnoringSafeArea(.all) NavigationStack { - VStack(spacing: 0) { - HeaderHome().environmentObject(counter) - menuOptions - Spacer() + ActivityLogBaseView(viewName: "Home", isDirectChildToContainer: true) { + VStack(spacing: 0) { + HeaderHome().environmentObject(counter) + menuOptions + Spacer() + } } .navigationTitle("") .navigationBarTitle("", displayMode: .inline) @@ -44,7 +45,6 @@ struct HomeView: View { } .onChange(of: counter.count, perform: { _ in if counter.count > UserDefaults.standard.double(forKey: bpmKEY) { -// print(counter.count) alertHeartRate() } }) @@ -52,7 +52,6 @@ struct HomeView: View { localNotification() } } - } } @ViewBuilder private var loadingOverlay: some View { diff --git a/Balance/Profile/ProfileView.swift b/Balance/Profile/ProfileView.swift index e6ef508..30b192f 100644 --- a/Balance/Profile/ProfileView.swift +++ b/Balance/Profile/ProfileView.swift @@ -182,7 +182,6 @@ struct ProfileView: View { } Button("Reset") { activityLogEntry.reset() - // NotificationCenter.default.post(name: Notification.Name.goBackground, object: nil) self.logs.removeAll() logsIsEmpty = true UserImageCache.remove(key: self.patientID.appending("UploadedArray")) @@ -198,24 +197,35 @@ struct ProfileView: View { var shareOption: some View { Button(action: { - EmailHelper.shared.send( - subject: "Balance Export", - body: "ParticipantID: " + self.patientID + "\n", - file: convertToCSV(), - fileName: self.patientID + ".csv", - plainText: convertToPlainText(), - to: ["acogburn@stanford.edu"] - ) + ActivityLogStore.load { result in + if case .success(let freshLogs) = result { + self.logs = freshLogs + EmailHelper.shared.send( + subject: "Balance Export", + body: "ParticipantID: " + self.patientID + "\n", + file: self.convertToCSV(), + fileName: self.patientID + ".csv", + plainText: self.convertToPlainText(), + to: ["acogburn@stanford.edu"] + ) + } + } }) { ProfileCellView(image: "mail", text: "E-Mail data") } } - + var shareLink: some View { - ShareLink( - item: convertToCSV(), - subject: Text("Balance Export: ParticipantID: " + self.patientID) - ) { + Button(action: { + ActivityLogStore.load { result in + if case .success(let freshLogs) = result { + self.logs = freshLogs + let url = self.convertToCSV() + let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil) + UIApplication.shared.currentUIWindow()?.rootViewController?.present(activityVC, animated: true) + } + } + }) { ProfileCellView(image: "square.and.arrow.up", text: "Share data") } } @@ -426,6 +436,7 @@ struct ProfileView: View { print(error.localizedDescription) case .success(let logs): self.logs = logs + print("[ProfileView][loadLogs]: Dump: \(self.logs.toJSON())") if self.logs.isEmpty { logsIsEmpty = true } else { @@ -435,66 +446,50 @@ struct ProfileView: View { } } - func convertToCSV() -> URL { + private func buildLogActions() -> [LogAction] { var logActions = [LogAction]() - var noteAsCSV = "sessionID, sessionStartTime, sessionEndTime, sessionDuration, description, startTime, endTime, duration\n" for log in logs { - for action in log.actions { + for entry in log.entries { logActions.append( LogAction( sessionID: log.id, sessionStartTime: log.startTime, sessionEndTime: log.endTime, sessionDuration: log.duration, - description: action.description, - startTime: action.startTime, - endTime: action.endTime, - duration: action.duration + description: entry.description, + startTime: entry.startTime, + endTime: entry.endTime, + duration: entry.duration ) ) } } - - for action in logActions.sorted(by: { $0.startTime.compare($1.startTime) == .orderedAscending }) { + return logActions.sorted { $0.startTime.compare($1.startTime) == .orderedAscending } + } + + func convertToCSV() -> URL { + let logActions = buildLogActions() + var noteAsCSV = "sessionID, sessionStartTime, sessionEndTime, sessionDuration, description, startTime, endTime, duration\n" + for action in logActions { noteAsCSV.append(contentsOf: "\"\(action.sessionID)\",\"\(DateFormatter.sharedDateFormatter.string(from: action.sessionStartTime))\",\"\(DateFormatter.sharedDateFormatter.string(from: action.sessionEndTime))\",\"\(action.sessionDuration)\",\"\(action.description)\",\"\(DateFormatter.sharedDateFormatter.string(from: action.startTime))\",\"\(DateFormatter.sharedDateFormatter.string(from: action.endTime))\",\"\(action.duration)\"\n") } - let fileManager = FileManager.default do { let path = try fileManager.url(for: .documentDirectory, in: .allDomainsMask, appropriateFor: nil, create: false) let fileURL = path.appendingPathComponent(self.patientID + ".csv") try noteAsCSV.write(to: fileURL, atomically: true, encoding: .utf8) - return fileURL } catch { print("error creating file") } return URL(fileURLWithPath: "") } - + func convertToPlainText() -> String { + let logActions = buildLogActions() var noteAsCSV = "ParticipantID: " + self.patientID + "\n" noteAsCSV.append(contentsOf: "sessionID, sessionStartTime, sessionEndTime, sessionDuration, description, startTime, endTime, duration\n") - - var logActions = [LogAction]() - for log in logs { - for action in log.actions { - logActions.append( - LogAction( - sessionID: log.id, - sessionStartTime: log.startTime, - sessionEndTime: log.endTime, - sessionDuration: log.duration, - description: action.description, - startTime: action.startTime, - endTime: action.endTime, - duration: action.duration - ) - ) - } - } - - for action in logActions.sorted(by: { $0.startTime.compare($1.startTime) == .orderedAscending }) { + for action in logActions { noteAsCSV.append(contentsOf: "\"\(action.sessionID)\",\"\(DateFormatter.sharedDateFormatter.string(from: action.sessionStartTime))\",\"\(DateFormatter.sharedDateFormatter.string(from: action.sessionEndTime))\",\"\(action.sessionDuration)\",\"\(action.description)\",\"\(DateFormatter.sharedDateFormatter.string(from: action.startTime))\",\"\(DateFormatter.sharedDateFormatter.string(from: action.endTime))\",\"\(action.duration)\"\n") } return noteAsCSV @@ -506,8 +501,8 @@ struct ProfileView: View { var counts: [String: Int] = [:] for log in logs { - for action in log.actions { - logActions.append(action.description) + for entry in log.entries { + logActions.append(entry.description) } } for item in logActions { From 7347976b65a9670ae9dbac240a05fad9edf661e2 Mon Sep 17 00:00:00 2001 From: Sebastian Ricaldoni Date: Tue, 9 Jun 2026 19:08:03 -0300 Subject: [PATCH 3/5] fix(lint): fix conditional_returns_on_newline violation in ActivityLogBaseView --- Balance/ActivityLogging/ActivityLogBaseView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Balance/ActivityLogging/ActivityLogBaseView.swift b/Balance/ActivityLogging/ActivityLogBaseView.swift index 62fa34e..c6bee12 100644 --- a/Balance/ActivityLogging/ActivityLogBaseView.swift +++ b/Balance/ActivityLogging/ActivityLogBaseView.swift @@ -56,7 +56,9 @@ struct ActivityLogBaseView: View where Content: View { #if DEBUG print("[ActivityLogBaseView][didBecomeActive] - View: \(viewName), isVisible: \(isVisible)") #endif - guard isVisible else { return } + guard isVisible else { + return + } if activityLogEntry.pendingEntry?.description != viewName { #if DEBUG print("[ActivityLogBaseView][didBecomeActive] - Re-pushing view: \(viewName)") From 405b51dc571b4785ce379f2306ebe1e19c91ea95 Mon Sep 17 00:00:00 2001 From: Sebastian Ricaldoni Date: Tue, 9 Jun 2026 19:56:48 -0300 Subject: [PATCH 4/5] fix(lint): remove superfluous force_unwrapping disable in SpotifyConfig --- Balance/Distraction/Music/iOS_SDK/SpotifyConfig.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Balance/Distraction/Music/iOS_SDK/SpotifyConfig.swift b/Balance/Distraction/Music/iOS_SDK/SpotifyConfig.swift index 3683c24..44da56f 100644 --- a/Balance/Distraction/Music/iOS_SDK/SpotifyConfig.swift +++ b/Balance/Distraction/Music/iOS_SDK/SpotifyConfig.swift @@ -8,7 +8,7 @@ import Foundation -// swiftlint:disable convenience_type force_unwrapping +// swiftlint:disable convenience_type struct SpotifyConfig { static let accessTokenKey = "access-token-key" static let redirectUri = URL(string: "balanceapp://spotify")! From 51e382377df9f4c60aafc958824a5fe329df2dd5 Mon Sep 17 00:00:00 2001 From: Sebastian Ricaldoni Date: Thu, 11 Jun 2026 12:28:26 -0300 Subject: [PATCH 5/5] fix: guard toJSON() debug call with #if DEBUG in ProfileView --- Balance/Profile/ProfileView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Balance/Profile/ProfileView.swift b/Balance/Profile/ProfileView.swift index 30b192f..57df252 100644 --- a/Balance/Profile/ProfileView.swift +++ b/Balance/Profile/ProfileView.swift @@ -436,7 +436,9 @@ struct ProfileView: View { print(error.localizedDescription) case .success(let logs): self.logs = logs +#if DEBUG print("[ProfileView][loadLogs]: Dump: \(self.logs.toJSON())") +#endif if self.logs.isEmpty { logsIsEmpty = true } else {