New Analytics feature

This commit is contained in:
Hamzenis Kryeziu 2025-03-02 17:31:58 +01:00
parent 37a596fc8e
commit 80e82e60ae
6 changed files with 280 additions and 0 deletions

View file

@ -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 = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
730C73552D726DA5001EC57C /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
7374EF372D74AEEA00319CEC /* UIDevice+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
/* 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 */,

View file

@ -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 }
}
}

View file

@ -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)
}()
}

View file

@ -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 {

View file

@ -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..<episodeChunkSize
}
@ -476,6 +479,7 @@ struct MediaInfoView: View {
func handleStreamFailure(error: Error? = nil) {
if let error = error {
Logger.shared.log("Error loading module: \(error)", type: "Error")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
}
DropManager.shared.showDrop(title: "Stream not Found", subtitle: "", duration: 1.0, icon: UIImage(systemName: "xmark"))

View file

@ -11,6 +11,7 @@ struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = true
@EnvironmentObject var settings: Settings
var body: some View {
@ -57,6 +58,10 @@ struct SettingsViewGeneral: View {
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
.tint(.accentColor)
}
Section(header: Text("Analytics"), footer: Text("Allow Sora to collect anonymous data to improve the app. No personal information is collected. This can be disabled at any time.\n\n Information collected: \n- App version\n- Device model\n- Module Name/Version\n- Error Messages\n- Title of Watched Content")) {
Toggle("Enable Analytics", isOn: $analyticsEnabled)
.tint(.accentColor)
}
}
.navigationTitle("General")
}