diff --git a/LifeSpace.xcodeproj/project.pbxproj b/LifeSpace.xcodeproj/project.pbxproj index 4cd7294..8e74545 100644 --- a/LifeSpace.xcodeproj/project.pbxproj +++ b/LifeSpace.xcodeproj/project.pbxproj @@ -55,6 +55,9 @@ 6347EB742BBBF442008E0C4A /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6347EB732BBBF442008E0C4A /* Constants.swift */; }; 63497B702BBF6ECE001F8419 /* LocationDataPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63497B6F2BBF6ECE001F8419 /* LocationDataPoint.swift */; }; 63497B732BBF855E001F8419 /* OptionsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63497B722BBF855E001F8419 /* OptionsPanel.swift */; }; + 634E38422CDE6B4000B16E20 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634E38412CDE6B3B00B16E20 /* LogManager.swift */; }; + 634E38442CDE6E2B00B16E20 /* LogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634E38432CDE6E2900B16E20 /* LogViewer.swift */; }; + 634E38482CDE7A7400B16E20 /* LogType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634E38472CDE7A7100B16E20 /* LogType.swift */; }; 634FFF672C169F40005E8217 /* LifeSpaceConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634FFF662C169F40005E8217 /* LifeSpaceConsent.swift */; }; 634FFF6D2C16B81A005E8217 /* HIPAAAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634FFF6C2C16B81A005E8217 /* HIPAAAuthorization.swift */; }; 635198792CD53FF40087B1F3 /* FirebaseConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635198782CD53FF10087B1F3 /* FirebaseConfiguration.swift */; }; @@ -137,6 +140,9 @@ 6347EB732BBBF442008E0C4A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 63497B6F2BBF6ECE001F8419 /* LocationDataPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataPoint.swift; sourceTree = ""; }; 63497B722BBF855E001F8419 /* OptionsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsPanel.swift; sourceTree = ""; }; + 634E38412CDE6B3B00B16E20 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; + 634E38432CDE6E2900B16E20 /* LogViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewer.swift; sourceTree = ""; }; + 634E38472CDE7A7100B16E20 /* LogType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogType.swift; sourceTree = ""; }; 634FFF662C169F40005E8217 /* LifeSpaceConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifeSpaceConsent.swift; sourceTree = ""; }; 634FFF6C2C16B81A005E8217 /* HIPAAAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HIPAAAuthorization.swift; sourceTree = ""; }; 635198782CD53FF10087B1F3 /* FirebaseConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfiguration.swift; sourceTree = ""; }; @@ -302,6 +308,16 @@ path = Map; sourceTree = ""; }; + 634E38462CDE790800B16E20 /* Debug */ = { + isa = PBXGroup; + children = ( + 634E38472CDE7A7100B16E20 /* LogType.swift */, + 634E38412CDE6B3B00B16E20 /* LogManager.swift */, + 634E38432CDE6E2900B16E20 /* LogViewer.swift */, + ); + path = Debug; + sourceTree = ""; + }; 635198772CD53FE30087B1F3 /* Firestore */ = { isa = PBXGroup; children = ( @@ -361,6 +377,7 @@ 653A254F283387FE005D4D48 /* LifeSpace */ = { isa = PBXGroup; children = ( + 634E38462CDE790800B16E20 /* Debug */, 635198772CD53FE30087B1F3 /* Firestore */, A9720E412ABB68B300872D23 /* Account */, 637AA5CF2BBDA686007BD7A3 /* Location */, @@ -634,6 +651,7 @@ 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, 634FFF672C169F40005E8217 /* LifeSpaceConsent.swift in Sources */, + 634E38482CDE7A7400B16E20 /* LogType.swift in Sources */, 2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */, 2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */, 630D3B572C616E9D006066E5 /* WithdrawView.swift in Sources */, @@ -664,8 +682,10 @@ 2F4E23832989D51F0013F3D9 /* LifeSpaceTestingSetup.swift in Sources */, 63F4C39B2BBCCCF80033D985 /* LocationModule.swift in Sources */, 6347EB742BBBF442008E0C4A /* Constants.swift in Sources */, + 634E38442CDE6E2B00B16E20 /* LogViewer.swift in Sources */, 63F4C39D2BBCCD200033D985 /* LocationUtils.swift in Sources */, 2FE5DC5329EDD7FA004B9AB4 /* Bundle+Questionnaire.swift in Sources */, + 634E38422CDE6B4000B16E20 /* LogManager.swift in Sources */, 634FFF6D2C16B81A005E8217 /* HIPAAAuthorization.swift in Sources */, 2FE5DC5129EDD7FA004B9AB4 /* LifeSpaceTaskContext.swift in Sources */, 63EA5F7B2BC04F8400A48590 /* DailySurveyTask.swift in Sources */, diff --git a/LifeSpace/Account/AccountSheet.swift b/LifeSpace/Account/AccountSheet.swift index c1ef67e..85fdeff 100644 --- a/LifeSpace/Account/AccountSheet.swift +++ b/LifeSpace/Account/AccountSheet.swift @@ -104,6 +104,9 @@ struct AccountSheet: View { locationTrackingToggle withdrawButton } + Section(header: Text("DEBUG_SECTION")) { + logExportButton + } } } @@ -170,6 +173,14 @@ struct AccountSheet: View { } } + private var logExportButton: some View { + NavigationLink(destination: { + LogViewer() + }) { + Text("VIEW_LOGS") + } + } + private func getDocumentURL(for fileName: String) -> URL? { guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { diff --git a/LifeSpace/Debug/LogManager.swift b/LifeSpace/Debug/LogManager.swift new file mode 100644 index 0000000..abe11af --- /dev/null +++ b/LifeSpace/Debug/LogManager.swift @@ -0,0 +1,61 @@ +// +// LogStore.swift +// LifeSpace +// +// Created by Vishnu Ravi on 11/8/24. +// + +import Foundation +import OSLog +import Spezi +import SwiftUI + +actor LogManager: Module, DefaultInitializable, EnvironmentAccessible { + @Application(\.logger) private var logger + + func query( + startDate: Date? = nil, + endDate: Date? = nil, + logType: OSLogEntryLog.Level? = nil + ) -> String { + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position: OSLogPosition + if let startDate = startDate { + position = store.position(date: startDate) + } else { + position = store.position(timeIntervalSinceLatestBoot: 1) + } + + let logs = try store.getEntries(at: position).compactMap { $0 as? OSLogEntryLog } + + return logs + .filter { logEntry in + /// Filter by subsystem + guard logEntry.subsystem == Bundle.main.bundleIdentifier else { + return false + } + + /// Filter by log type if specified + if let logType = logType, logEntry.level != logType { + return false + } + + /// Filter by date range if specified + if let startDate = startDate, logEntry.date < startDate { + return false + } + if let endDate = endDate, logEntry.date > endDate { + return false + } + + return true + } + .map { "[\($0.date.formatted())] [\($0.category)] \($0.composedMessage)" } + .joined(separator: "\n") + } catch { + logger.warning("\(error.localizedDescription, privacy: .public)") + return "" + } + } +} diff --git a/LifeSpace/Debug/LogType.swift b/LifeSpace/Debug/LogType.swift new file mode 100644 index 0000000..b09c383 --- /dev/null +++ b/LifeSpace/Debug/LogType.swift @@ -0,0 +1,34 @@ +// +// LogType.swift +// LifeSpace +// +// Created by Vishnu Ravi on 11/8/24. +// + +import OSLog + + +enum LogType: String, CaseIterable, Identifiable { + case all = "All" + case info = "Info" + case debug = "Debug" + case error = "Error" + case fault = "Fault" + + var id: String { self.rawValue } + + var osLogLevel: OSLogEntryLog.Level? { + switch self { + case .all: + return nil + case .info: + return .info + case .debug: + return .debug + case .error: + return .error + case .fault: + return .fault + } + } +} diff --git a/LifeSpace/Debug/LogViewer.swift b/LifeSpace/Debug/LogViewer.swift new file mode 100644 index 0000000..b9ba06e --- /dev/null +++ b/LifeSpace/Debug/LogViewer.swift @@ -0,0 +1,95 @@ +// +// LogShareView.swift +// LifeSpace +// +// Created by Vishnu Ravi on 11/8/24. +// + +import OSLog +import Spezi +import SwiftUI + + +struct LogViewer: View { + @Environment(LogManager.self) var manager + + @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + @State private var endDate = Date() + @State private var selectedLogType: LogType = .all + @State private var logs = "" + @State private var isLoading = false + + var body: some View { + VStack { + /// Date range selection + HStack { + DatePicker("FROM", selection: $startDate, displayedComponents: .date) + Spacer() + DatePicker("TO", selection: $endDate, displayedComponents: .date) + } + .padding() + + /// Log type selection + Picker("LOG_TYPE", selection: $selectedLogType) { + ForEach(LogType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding() + + ScrollView { + if isLoading { + ProgressView("LOADING_LOGS") + .padding() + } else { + Text(logs) + .padding() + } + } + } + .navigationTitle("LOG_VIEWER") + .onAppear { + Task { + await queryLogs() + } + } + .onChange(of: startDate) { + Task { + await queryLogs() + } + } + .onChange(of: endDate) { + Task { + await queryLogs() + } + } + .onChange(of: selectedLogType) { + Task { + await queryLogs() + } + } + .toolbar { + if !logs.isEmpty { + ShareLink( + item: logs, + preview: SharePreview("LOGS", image: Image(systemName: "doc.text")) + ) { + Image(systemName: "square.and.arrow.up") + } + } + } + } + + @MainActor + private func queryLogs() async { + isLoading = true + + /// This is very slow, so run as a detached task with high priority + logs = await Task.detached(priority: .userInitiated) { [manager, startDate, endDate, selectedLogType] in + await manager.query(startDate: startDate, endDate: endDate, logType: selectedLogType.osLogLevel) + }.value + + isLoading = false + } +} diff --git a/LifeSpace/Resources/Localizable.xcstrings b/LifeSpace/Resources/Localizable.xcstrings index ecc6592..56d4279 100644 --- a/LifeSpace/Resources/Localizable.xcstrings +++ b/LifeSpace/Resources/Localizable.xcstrings @@ -163,8 +163,25 @@ } } }, + "DEBUG_SECTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + } + } + }, "DOCUMENT_NOT_FOUND_MESSAGE" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Document not found" + } + } + } }, "DOCUMENTS_SECTION" : { "localizations" : { @@ -176,6 +193,16 @@ } } }, + "FROM" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "From" + } + } + } + }, "HEALTHKIT_PERMISSIONS_BUTTON" : { "localizations" : { "en" : { @@ -298,6 +325,16 @@ } } }, + "LOADING_LOGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading logsā€¦" + } + } + } + }, "LOCATION_ALLOW_WHILE_USING_DESCRIPTION" : { "localizations" : { "en" : { @@ -388,6 +425,36 @@ } } }, + "LOG_TYPE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log Type" + } + } + } + }, + "LOG_VIEWER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log Viewer" + } + } + } + }, + "LOGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, "NEXT_BUTTON" : { "localizations" : { "en" : { @@ -689,6 +756,16 @@ } } }, + "TO" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To" + } + } + } + }, "TRACK_LOCATION_BUTTON" : { "localizations" : { "en" : { @@ -729,6 +806,16 @@ } } }, + "VIEW_LOGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Logs" + } + } + } + }, "VIEW_PRIVACY_POLICY" : { "localizations" : { "en" : { diff --git a/LifeSpaceDelegate.swift b/LifeSpaceDelegate.swift index a204ee6..d08bc43 100644 --- a/LifeSpaceDelegate.swift +++ b/LifeSpaceDelegate.swift @@ -50,6 +50,7 @@ class LifeSpaceDelegate: SpeziAppDelegate { LifeSpaceScheduler() OnboardingDataSource() LocationModule() + LogManager() } }