From 80e82e60ae35b5eec178caf7a627a4eef701ee87 Mon Sep 17 00:00:00 2001 From: Hamzenis Kryeziu Date: Sun, 2 Mar 2025 17:31:58 +0100 Subject: [PATCH] New Analytics feature --- Sora.xcodeproj/project.pbxproj | 7 + Sora/Utils/Analytics/Analytics.swift | 125 +++++++++++++++++ Sora/Utils/Analytics/UIDevice+Model.swift | 132 ++++++++++++++++++ Sora/Utils/Logger/Logger.swift | 7 + Sora/Views/MediaInfoView/MediaInfoView.swift | 4 + .../SettingsViewGeneral.swift | 5 + 6 files changed, 280 insertions(+) create mode 100644 Sora/Utils/Analytics/Analytics.swift create mode 100644 Sora/Utils/Analytics/UIDevice+Model.swift diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index cdd7991..2a756bf 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -51,6 +51,8 @@ 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; + 730C73562D726DAB001EC57C /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730C73552D726DA5001EC57C /* Analytics.swift */; }; + 7374EF382D74AEF400319CEC /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7374EF372D74AEEA00319CEC /* UIDevice+Model.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -99,6 +101,8 @@ 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; + 730C73552D726DA5001EC57C /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; + 7374EF372D74AEEA00319CEC /* UIDevice+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -236,6 +240,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 730C73542D726D83001EC57C /* Analytics */, 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, 13DC0C442D302C6A00D0F966 /* MediaPlayer */, @@ -458,6 +463,7 @@ 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, + 730C73562D726DAB001EC57C /* Analytics.swift in Sources */, 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, @@ -475,6 +481,7 @@ 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */, 133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */, 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */, + 7374EF382D74AEF400319CEC /* UIDevice+Model.swift in Sources */, 13103E8B2D58E028000F0673 /* View.swift in Sources */, 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */, 133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */, diff --git a/Sora/Utils/Analytics/Analytics.swift b/Sora/Utils/Analytics/Analytics.swift new file mode 100644 index 0000000..1f4cc0f --- /dev/null +++ b/Sora/Utils/Analytics/Analytics.swift @@ -0,0 +1,125 @@ +// +// Analytics.swift +// Sora +// +// Created by Hamzo on 28.02.25. +// + +import Foundation +import UIKit + +// MARK: - Analytics Response Model +struct AnalyticsResponse: Codable { + let status: String + let message: String + let event: String? + let timestamp: String? +} + +// MARK: - Analytics Manager +class AnalyticsManager { + + static let shared = AnalyticsManager() + private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")! + private let moduleManager = ModuleManager() + + private init() {} + + // MARK: - Send Analytics Data + func sendEvent(event: String, additionalData: [String: Any] = [:]) { + + let defaults = UserDefaults.standard + + // Ensure the key is set with a default value if missing + if defaults.object(forKey: "analyticsEnabled") == nil { + print("Setting default value for analyticsEnabled") + defaults.setValue(true, forKey: "analyticsEnabled") + } + + + let analyticsEnabled = UserDefaults.standard.bool(forKey: "analyticsEnabled") + + guard analyticsEnabled else { + Logger.shared.log("Analytics is disabled, skipping event: \(event)", type: "Debug") + return + } + + guard let selectedModule = getSelectedModule() else { + Logger.shared.log("No selected module found", type: "Debug") + return + } + + // Prepare analytics data + var safeAdditionalData = additionalData + + // Check and convert NSError if present + if let errorValue = additionalData["error"] as? NSError { + safeAdditionalData["error"] = errorValue.localizedDescription + } + + let analyticsData: [String: Any] = [ + "event": event, + "device": getDeviceModel(), + "app_version": getAppVersion(), + "module_name": selectedModule.metadata.sourceName, + "module_version": selectedModule.metadata.version, + "data": safeAdditionalData + ] + + sendRequest(with: analyticsData) + } + + // MARK: - Private Request Method + private func sendRequest(with data: [String: Any]) { + var request = URLRequest(url: analyticsURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: data, options: []) + } catch { + Logger.shared.log("Failed to encode JSON: \(error.localizedDescription)", type: "Debug") + return + } + + URLSession.shared.dataTask(with: request) { (data, response, error) in + if let error = error { + Logger.shared.log("Request failed: \(error.localizedDescription)", type: "Debug") + return + } + + guard let data = data else { + Logger.shared.log("No data received from server", type: "Debug") + return + } + + do { + let decodedResponse = try JSONDecoder().decode(AnalyticsResponse.self, from: data) + if decodedResponse.status == "success" { + Logger.shared.log("Analytics saved: \(decodedResponse.event ?? "unknown event") at \(decodedResponse.timestamp ?? "unknown time")", type: "Debug") + } else { + Logger.shared.log("Server error: \(decodedResponse.message)", type: "Debug") + } + } catch { + Logger.shared.log("Failed to decode response: \(error.localizedDescription)", type: "Debug") + } + }.resume() + } + + // MARK: - Get App Version + private func getAppVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown_version" + } + + // MARK: - Get Device Model + private func getDeviceModel() -> String { + return UIDevice.modelName + } + + + // MARK: - Get Selected Module + private func getSelectedModule() -> ScrapingModule? { + guard let selectedModuleId = UserDefaults.standard.string(forKey: "selectedModuleId") else { return nil } + return moduleManager.modules.first { $0.id.uuidString == selectedModuleId } + } +} diff --git a/Sora/Utils/Analytics/UIDevice+Model.swift b/Sora/Utils/Analytics/UIDevice+Model.swift new file mode 100644 index 0000000..8ae431c --- /dev/null +++ b/Sora/Utils/Analytics/UIDevice+Model.swift @@ -0,0 +1,132 @@ +// +// UIDevice+Model.swift +// Sora +// +// Created by Hamzo on 02.03.25. +// + +import UIKit + +public extension UIDevice { + + static let modelName: String = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity + #if os(iOS) + switch identifier { + case "iPod5,1": return "iPod touch (5th generation)" + case "iPod7,1": return "iPod touch (6th generation)" + case "iPod9,1": return "iPod touch (7th generation)" + case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4" + case "iPhone4,1": return "iPhone 4s" + case "iPhone5,1", "iPhone5,2": return "iPhone 5" + case "iPhone5,3", "iPhone5,4": return "iPhone 5c" + case "iPhone6,1", "iPhone6,2": return "iPhone 5s" + case "iPhone7,2": return "iPhone 6" + case "iPhone7,1": return "iPhone 6 Plus" + case "iPhone8,1": return "iPhone 6s" + case "iPhone8,2": return "iPhone 6s Plus" + case "iPhone9,1", "iPhone9,3": return "iPhone 7" + case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus" + case "iPhone10,1", "iPhone10,4": return "iPhone 8" + case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus" + case "iPhone10,3", "iPhone10,6": return "iPhone X" + case "iPhone11,2": return "iPhone XS" + case "iPhone11,4", "iPhone11,6": return "iPhone XS Max" + case "iPhone11,8": return "iPhone XR" + case "iPhone12,1": return "iPhone 11" + case "iPhone12,3": return "iPhone 11 Pro" + case "iPhone12,5": return "iPhone 11 Pro Max" + case "iPhone13,1": return "iPhone 12 mini" + case "iPhone13,2": return "iPhone 12" + case "iPhone13,3": return "iPhone 12 Pro" + case "iPhone13,4": return "iPhone 12 Pro Max" + case "iPhone14,4": return "iPhone 13 mini" + case "iPhone14,5": return "iPhone 13" + case "iPhone14,2": return "iPhone 13 Pro" + case "iPhone14,3": return "iPhone 13 Pro Max" + case "iPhone14,7": return "iPhone 14" + case "iPhone14,8": return "iPhone 14 Plus" + case "iPhone15,2": return "iPhone 14 Pro" + case "iPhone15,3": return "iPhone 14 Pro Max" + case "iPhone15,4": return "iPhone 15" + case "iPhone15,5": return "iPhone 15 Plus" + case "iPhone16,1": return "iPhone 15 Pro" + case "iPhone16,2": return "iPhone 15 Pro Max" + case "iPhone17,3": return "iPhone 16" + case "iPhone17,4": return "iPhone 16 Plus" + case "iPhone17,1": return "iPhone 16 Pro" + case "iPhone17,2": return "iPhone 16 Pro Max" + case "iPhone8,4": return "iPhone SE" + case "iPhone12,8": return "iPhone SE (2nd generation)" + case "iPhone14,6": return "iPhone SE (3rd generation)" + case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2" + case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)" + case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)" + case "iPad6,11", "iPad6,12": return "iPad (5th generation)" + case "iPad7,5", "iPad7,6": return "iPad (6th generation)" + case "iPad7,11", "iPad7,12": return "iPad (7th generation)" + case "iPad11,6", "iPad11,7": return "iPad (8th generation)" + case "iPad12,1", "iPad12,2": return "iPad (9th generation)" + case "iPad13,18", "iPad13,19": return "iPad (10th generation)" + case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air" + case "iPad5,3", "iPad5,4": return "iPad Air 2" + case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)" + case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)" + case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)" + case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)" + case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)" + case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini" + case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2" + case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3" + case "iPad5,1", "iPad5,2": return "iPad mini 4" + case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)" + case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)" + case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)" + case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)" + case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)" + case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)" + case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)" + case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)" + case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)" + case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)" + case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)" + case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)" + case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)" + case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)" + case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)" + case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)" + case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)" + case "AppleTV5,3": return "Apple TV" + case "AppleTV6,2": return "Apple TV 4K" + case "AudioAccessory1,1": return "HomePod" + case "AudioAccessory5,1": return "HomePod mini" + case "i386", "x86_64", "arm64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))" + default: return identifier + } + #elseif os(tvOS) + switch identifier { + case "AppleTV5,3": return "Apple TV 4" + case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1": return "Apple TV 4K" + case "i386", "x86_64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))" + default: return identifier + } + #elseif os(visionOS) + switch identifier { + case "RealityDevice14,1": return "Apple Vision Pro" + default: return identifier + } + #endif + } + + return mapToDevice(identifier: identifier) + }() + +} diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift index 36194c8..07337ed 100644 --- a/Sora/Utils/Logger/Logger.swift +++ b/Sora/Utils/Logger/Logger.swift @@ -31,6 +31,13 @@ class Logger { let entry = LogEntry(message: message, type: type, timestamp: Date()) logs.append(entry) saveLogToFile(entry) + + // Print to Xcode console + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)" + + //print(formattedMessage) // TODO: Remove this line in production, DEBUG only } func getLogs() -> String { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index a52b0b5..a1dca9c 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -214,6 +214,7 @@ struct MediaInfoView: View { selectedEpisodeNumber = ep.number selectedEpisodeImage = imageUrl fetchStream(href: ep.href) + AnalyticsManager.shared.sendEvent(event: "watch", additionalData: ["title": title, "episode": ep.number]) } } ) @@ -268,9 +269,11 @@ struct MediaInfoView: View { itemID = id case .failure(let error): Logger.shared.log("Failed to fetch Item ID: \(error)") + AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch Item ID"]) } } hasFetched = true + AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title]) } selectedRange = 0..