1 // Copyright 2022-present 650 Industries. All rights reserved.
2 
3 import Foundation
4 
5 public typealias PersistentFileLogFilter = (String) -> Bool
6 public typealias PersistentFileLogCompletionHandler = (Error?) -> Void
7 
8 /**
9  * A thread-safe class for reading and writing line-separated strings to a flat file.
10  * The main use case is for logging specific errors or events, and ensuring that the
11  * logs persist across application crashes and restarts (for example, OSLogReader can
12  * only read system logs for the current process, and cannot access anything logged
13  * before the current process started).
14  *
15  * All write access to the file goes through asynchronous public methods managed by a
16  * serial dispatch queue.
17  *
18  * The dispatch queue is global, to ensure that multiple instances accessing the same
19  * file will have thread-safe access.
20  *
21  * The only operations supported are
22  * - Read the file (synchronous)
23  * - Append one or more entries to the file
24  * - Filter the file (only retain entries that pass the filter check)
25  * - Clear the file (remove all entries)
26  *
27  */
28 public class PersistentFileLog {
29   private static let EXPO_MODULES_CORE_LOG_QUEUE_LABEL = "dev.expo.modules.core.logging"
30   private static let serialQueue = DispatchQueue(label: EXPO_MODULES_CORE_LOG_QUEUE_LABEL)
31 
32   private let category: String
33   private let filePath: String
34 
35   public init(category: String) {
36     self.category = category
37     let fileName = "\(PersistentFileLog.EXPO_MODULES_CORE_LOG_QUEUE_LABEL).\(category).txt"
38     // Execution aborts if no application support directory
39     self.filePath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName).path
40   }
41   /**
42    Read entries from log file
43    */
readEntriesnull44   public func readEntries() -> [String] {
45     if getFileSize() == 0 {
46       return []
47     }
48     return (try? self.readFileSync()) ?? []
49   }
50 
51   /**
52    Append entry to the log file
53    Since logging may not require a result handler, the handler parameter is optional
54    If no error handler provided, print error to the console
55    */
appendEntrynull56   public func appendEntry(entry: String, _ completionHandler: PersistentFileLogCompletionHandler? = nil) {
57     PersistentFileLog.serialQueue.async {
58       self.ensureFileExists()
59       self.appendTextToFile(text: entry + "\n")
60       completionHandler?(nil)
61     }
62   }
63 
64   /**
65    Filter existing entries and remove ones where filter(entry) == false
66    */
purgeEntriesNotMatchingFilternull67   public func purgeEntriesNotMatchingFilter(filter: @escaping PersistentFileLogFilter, _ completionHandler: @escaping PersistentFileLogCompletionHandler) {
68     PersistentFileLog.serialQueue.async {
69       self.ensureFileExists()
70       do {
71         let contents = try self.readFileSync()
72         let newcontents = contents.filter { entry in filter(entry) }
73         try self.writeFileSync(newcontents)
74         completionHandler(nil)
75       } catch {
76         completionHandler(error)
77       }
78     }
79   }
80 
81   /**
82    Clean up (remove) the log file
83    */
clearEntriesnull84   public func clearEntries(_ completionHandler: @escaping PersistentFileLogCompletionHandler) {
85     PersistentFileLog.serialQueue.async {
86       do {
87         try self.deleteFileSync()
88         completionHandler(nil)
89       } catch {
90         completionHandler(error)
91       }
92     }
93   }
94 
95   // MARK: - Private methods
96 
ensureFileExistsnull97   private func ensureFileExists() {
98     if !FileManager.default.fileExists(atPath: filePath) {
99       FileManager.default.createFile(atPath: filePath, contents: nil)
100     }
101   }
102 
getFileSizenull103   private func getFileSize() -> Int {
104     // Gets the file size, or returns 0 if the file does not exist
105     do {
106       let attrs: [FileAttributeKey: Any?] = try FileManager.default.attributesOfItem(atPath: filePath)
107       return attrs[FileAttributeKey.size] as? Int ?? 0
108     } catch {
109       return 0
110     }
111   }
112 
appendTextToFilenull113   private func appendTextToFile(text: String) {
114     if let data = text.data(using: .utf8) {
115       if let fileHandle = FileHandle(forWritingAtPath: filePath) {
116         fileHandle.seekToEndOfFile()
117         fileHandle.write(data)
118         fileHandle.closeFile()
119       }
120     }
121   }
122 
readFileSyncnull123   private func readFileSync() throws -> [String] {
124     return try stringToList(String(contentsOfFile: filePath, encoding: .utf8))
125   }
126 
writeFileSyncnull127   private func writeFileSync(_ contents: [String]) throws {
128     if contents.isEmpty {
129       try deleteFileSync()
130       return
131     }
132     try contents.joined(separator: "\n").write(toFile: filePath, atomically: true, encoding: .utf8)
133   }
134 
deleteFileSyncnull135   private func deleteFileSync() throws {
136     if FileManager.default.fileExists(atPath: filePath) {
137       try FileManager.default.removeItem(atPath: filePath)
138     }
139   }
140 
stringToListnull141   private func stringToList(_ contents: String?) -> [String] {
142     // If null contents, or 0 length contents, return empty list
143     guard let contents = contents, !contents.isEmpty else {
144       return []
145     }
146     return contents
147       .components(separatedBy: "\n")
148       .filter {entryString in
149         !entryString.isEmpty
150       }
151   }
152 }
153