321 lines
9.9 KiB
Swift
321 lines
9.9 KiB
Swift
import Flutter
|
|
import UIKit
|
|
import AdSupport
|
|
import AppTrackingTransparency
|
|
import CFNetwork
|
|
import CoreTelephony
|
|
import Darwin
|
|
|
|
public class AnalyticsSdkPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
|
private var proximityEventSink: FlutterEventSink?
|
|
private var proximityMonitoringWasEnabled = false
|
|
|
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
|
let channel = FlutterMethodChannel(name: "analytics_sdk", binaryMessenger: registrar.messenger())
|
|
let instance = AnalyticsSdkPlugin()
|
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
let proximityChannel = FlutterEventChannel(
|
|
name: "analytics_sdk/proximity",
|
|
binaryMessenger: registrar.messenger()
|
|
)
|
|
proximityChannel.setStreamHandler(instance)
|
|
}
|
|
|
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "getPlatformVersion":
|
|
result("iOS " + UIDevice.current.systemVersion)
|
|
case "getDeviceId":
|
|
result(UIDevice.current.identifierForVendor?.uuidString ?? "")
|
|
case "getSdkInitPlatformFields":
|
|
result(sdkInitPlatformFields())
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
public func onListen(
|
|
withArguments arguments: Any?,
|
|
eventSink events: @escaping FlutterEventSink
|
|
) -> FlutterError? {
|
|
_ = onCancel(withArguments: nil)
|
|
proximityEventSink = events
|
|
proximityMonitoringWasEnabled = UIDevice.current.isProximityMonitoringEnabled
|
|
UIDevice.current.isProximityMonitoringEnabled = true
|
|
guard UIDevice.current.isProximityMonitoringEnabled else {
|
|
proximityEventSink = nil
|
|
return FlutterError(
|
|
code: "proximity_unavailable",
|
|
message: "Proximity monitoring unavailable",
|
|
details: nil
|
|
)
|
|
}
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(proximityStateDidChange),
|
|
name: UIDevice.proximityStateDidChangeNotification,
|
|
object: UIDevice.current
|
|
)
|
|
events(UIDevice.current.proximityState ? 1 : 0)
|
|
return nil
|
|
}
|
|
|
|
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
|
NotificationCenter.default.removeObserver(
|
|
self,
|
|
name: UIDevice.proximityStateDidChangeNotification,
|
|
object: UIDevice.current
|
|
)
|
|
UIDevice.current.isProximityMonitoringEnabled = proximityMonitoringWasEnabled
|
|
proximityEventSink = nil
|
|
return nil
|
|
}
|
|
|
|
@objc private func proximityStateDidChange() {
|
|
proximityEventSink?(UIDevice.current.proximityState ? 1 : 0)
|
|
}
|
|
|
|
private func sdkInitPlatformFields() -> [String: Any] {
|
|
var fields: [String: Any] = [
|
|
"language": normalizedLocaleIdentifier(),
|
|
"timezone": TimeZone.current.identifier,
|
|
"bundle_id": Bundle.main.bundleIdentifier ?? "",
|
|
"ios_device_model_raw": rawDeviceModel(),
|
|
"idfv": UIDevice.current.identifierForVendor?.uuidString ?? "",
|
|
"is_jailbroken": isJailbroken() ? 1 : 0,
|
|
"cpu_cores": ProcessInfo.processInfo.activeProcessorCount,
|
|
"device_memory_gb": bytesToGb(Int64(ProcessInfo.processInfo.physicalMemory)),
|
|
"touch_support": 1
|
|
]
|
|
|
|
fields.merge(screenFields()) { _, new in new }
|
|
fields.merge(storageFields()) { _, new in new }
|
|
fields.merge(batteryFields()) { _, new in new }
|
|
fields.merge(telephonyFields()) { _, new in new }
|
|
fields.merge(advertisingFields()) { _, new in new }
|
|
fields.merge(networkStateFields()) { _, new in new }
|
|
|
|
return fields
|
|
}
|
|
|
|
private func normalizedLocaleIdentifier() -> String {
|
|
return Locale.current.identifier.replacingOccurrences(of: "_", with: "-")
|
|
}
|
|
|
|
private func rawDeviceModel() -> String {
|
|
var systemInfo = utsname()
|
|
uname(&systemInfo)
|
|
let mirror = Mirror(reflecting: systemInfo.machine)
|
|
let identifier = mirror.children.reduce(into: "") { value, element in
|
|
guard let byte = element.value as? Int8, byte != 0 else { return }
|
|
value.append(String(UnicodeScalar(UInt8(byte))))
|
|
}
|
|
return identifier
|
|
}
|
|
|
|
private func screenFields() -> [String: Any] {
|
|
let screen = UIScreen.main
|
|
let nativeBounds = screen.nativeBounds
|
|
let scale = screen.scale
|
|
return [
|
|
"screen_width": Int(nativeBounds.width.rounded()),
|
|
"screen_height": Int(nativeBounds.height.rounded()),
|
|
"screen_dpi": Int((scale * 160).rounded()),
|
|
"device_pixel_ratio": scale,
|
|
"orientation": interfaceOrientation()
|
|
]
|
|
}
|
|
|
|
private func interfaceOrientation() -> String {
|
|
if #available(iOS 13.0, *) {
|
|
let orientation = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first?
|
|
.interfaceOrientation
|
|
if orientation?.isLandscape == true {
|
|
return "landscape"
|
|
}
|
|
if orientation?.isPortrait == true {
|
|
return "portrait"
|
|
}
|
|
} else {
|
|
let orientation = UIApplication.shared.statusBarOrientation
|
|
if orientation.isLandscape {
|
|
return "landscape"
|
|
}
|
|
if orientation.isPortrait {
|
|
return "portrait"
|
|
}
|
|
}
|
|
return screenFallbackOrientation()
|
|
}
|
|
|
|
private func screenFallbackOrientation() -> String {
|
|
let bounds = UIScreen.main.nativeBounds
|
|
return bounds.width >= bounds.height ? "landscape" : "portrait"
|
|
}
|
|
|
|
private func storageFields() -> [String: Any] {
|
|
do {
|
|
let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
|
let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? -1
|
|
let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? -1
|
|
return [
|
|
"storage": bytesToGb(total),
|
|
"available_storage": bytesToGb(free)
|
|
]
|
|
} catch {
|
|
return ["storage": -1, "available_storage": -1]
|
|
}
|
|
}
|
|
|
|
private func batteryFields() -> [String: Any] {
|
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
let level = UIDevice.current.batteryLevel
|
|
let batteryLevel = level >= 0 ? Int((level * 100).rounded()) : -1
|
|
let charging: Int
|
|
switch UIDevice.current.batteryState {
|
|
case .charging, .full:
|
|
charging = 1
|
|
case .unplugged:
|
|
charging = 0
|
|
default:
|
|
charging = -1
|
|
}
|
|
return ["battery_level": batteryLevel, "is_charging": charging]
|
|
}
|
|
|
|
private func telephonyFields() -> [String: Any] {
|
|
var fields: [String: Any] = [
|
|
"carrier": "",
|
|
"mcc": "unknown",
|
|
"mnc": "unknown",
|
|
"has_sim": -1
|
|
]
|
|
|
|
let info = CTTelephonyNetworkInfo()
|
|
var carrier: CTCarrier?
|
|
if #available(iOS 12.0, *) {
|
|
carrier = info.serviceSubscriberCellularProviders?.values.first
|
|
} else {
|
|
carrier = info.subscriberCellularProvider
|
|
}
|
|
|
|
guard let carrier = carrier else {
|
|
fields["has_sim"] = 0
|
|
return fields
|
|
}
|
|
|
|
fields["carrier"] = carrier.carrierName ?? ""
|
|
fields["mcc"] = carrier.mobileCountryCode ?? "unknown"
|
|
fields["mnc"] = carrier.mobileNetworkCode ?? "unknown"
|
|
fields["has_sim"] = 1
|
|
return fields
|
|
}
|
|
|
|
private func advertisingFields() -> [String: Any] {
|
|
if #available(iOS 14, *) {
|
|
switch ATTrackingManager.trackingAuthorizationStatus {
|
|
case .authorized:
|
|
return [
|
|
"idfa": ASIdentifierManager.shared().advertisingIdentifier.uuidString,
|
|
"att_status": "authorized",
|
|
"lat": 0
|
|
]
|
|
case .denied:
|
|
return ["idfa": "", "att_status": "denied", "lat": 1]
|
|
case .restricted:
|
|
return ["idfa": "", "att_status": "restricted", "lat": 1]
|
|
case .notDetermined:
|
|
return ["idfa": "", "att_status": "not_determined", "lat": -1]
|
|
@unknown default:
|
|
return ["idfa": "", "att_status": "unknown", "lat": -1]
|
|
}
|
|
} else {
|
|
let manager = ASIdentifierManager.shared()
|
|
let trackingEnabled = manager.isAdvertisingTrackingEnabled
|
|
return [
|
|
"idfa": trackingEnabled ? manager.advertisingIdentifier.uuidString : "",
|
|
"att_status": "unknown",
|
|
"lat": trackingEnabled ? 0 : 1
|
|
]
|
|
}
|
|
}
|
|
|
|
private func networkStateFields() -> [String: Any] {
|
|
return [
|
|
"vpn_enabled": isVpnEnabled() ? 1 : 0,
|
|
"proxy_enabled": isProxyEnabled()
|
|
]
|
|
}
|
|
|
|
private func isProxyEnabled() -> Int {
|
|
guard
|
|
let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any]
|
|
else {
|
|
return -1
|
|
}
|
|
|
|
let httpEnabled = (settings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.intValue == 1
|
|
let httpsEnabled = (settings["HTTPSEnable"] as? NSNumber)?.intValue == 1
|
|
return httpEnabled || httpsEnabled ? 1 : 0
|
|
}
|
|
|
|
private func isVpnEnabled() -> Bool {
|
|
var addresses: UnsafeMutablePointer<ifaddrs>?
|
|
guard getifaddrs(&addresses) == 0, let firstAddress = addresses else {
|
|
return false
|
|
}
|
|
defer { freeifaddrs(firstAddress) }
|
|
|
|
var pointer: UnsafeMutablePointer<ifaddrs>? = firstAddress
|
|
while pointer != nil {
|
|
if let name = pointer?.pointee.ifa_name {
|
|
let interface = String(cString: name)
|
|
if interface.hasPrefix("utun") ||
|
|
interface.hasPrefix("ppp") ||
|
|
interface.hasPrefix("ipsec") ||
|
|
interface.hasPrefix("tap") ||
|
|
interface.hasPrefix("tun") {
|
|
return true
|
|
}
|
|
}
|
|
pointer = pointer?.pointee.ifa_next
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func isJailbroken() -> Bool {
|
|
#if targetEnvironment(simulator)
|
|
return false
|
|
#else
|
|
let paths = [
|
|
"/Applications/Cydia.app",
|
|
"/Library/MobileSubstrate/MobileSubstrate.dylib",
|
|
"/bin/bash",
|
|
"/usr/sbin/sshd",
|
|
"/etc/apt",
|
|
"/private/var/lib/apt/"
|
|
]
|
|
if paths.contains(where: { FileManager.default.fileExists(atPath: $0) }) {
|
|
return true
|
|
}
|
|
do {
|
|
let path = "/private/analytics_sdk_jailbreak_test.txt"
|
|
try "test".write(toFile: path, atomically: true, encoding: .utf8)
|
|
try FileManager.default.removeItem(atPath: path)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func bytesToGb(_ bytes: Int64) -> Int64 {
|
|
if bytes <= 0 {
|
|
return -1
|
|
}
|
|
return bytes / (1024 * 1024 * 1024)
|
|
}
|
|
}
|