From 413b89847a1a341c6291316b6ccf99afea8c3b0a Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 31 Mar 2026 16:03:50 -0400 Subject: [PATCH 1/2] Add AppInfo and DeviceInfo for providing information about the current app and device --- README.md | 290 +++++++++++++++++ Sources/SkipKit/AppInfo.swift | 297 +++++++++++++++++ Sources/SkipKit/DeviceInfo.swift | 439 ++++++++++++++++++++++++++ Tests/SkipKitTests/SkipKitTests.swift | 192 +++++++++++ 4 files changed, 1218 insertions(+) create mode 100644 Sources/SkipKit/AppInfo.swift create mode 100644 Sources/SkipKit/DeviceInfo.swift diff --git a/README.md b/README.md index 071a34b..a6b898d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,296 @@ cache.putValue(Data(count: 1), for: UUID()) // total cost = 101, so cache will e ``` +## AppInfo + +`AppInfo.current` provides read-only access to information about the currently running application, including its version, name, bundle identifier, build configuration, and platform details. It works consistently on both iOS and Android. + +### Basic Usage + +```swift +import SkipKit + +let info = AppInfo.current + +// Version and identity +print("App Name: \(info.displayName ?? "Unknown")") +print("App ID: \(info.appIdentifier ?? "Unknown")") +print("Version: \(info.version ?? "Unknown")") +print("Build: \(info.buildNumber ?? "Unknown")") +print("Full: \(info.versionWithBuild)") // e.g. "1.2.3 (42)" + +// Build configuration +if info.isDebug { + print("Running in debug mode") +} + +if info.isTestFlight { + print("Installed from TestFlight") +} + +// Platform +print("OS: \(info.osName) \(info.osVersion)") // e.g. "iOS 17.4.1" or "Android 34" +print("Device: \(info.deviceModel)") // e.g. "iPhone15,2" or "Pixel 7" +``` + +### User-Facing Version String + +```swift +Text("Version \(AppInfo.current.versionWithBuild)") +// Displays: "Version 1.2.3 (42)" +``` + +### Debug vs Release + +```swift +if AppInfo.current.isDebug { + // Show debug tools, logging, etc. +} else { + // Production behavior +} +``` + +### iOS Info.plist Access + +On iOS, you can query raw Info.plist values: + +```swift +let keys = AppInfo.current.infoDictionaryKeys +if let minOS = AppInfo.current.infoDictionaryValue(forKey: "MinimumOSVersion") as? String { + print("Requires iOS \(minOS)") +} +``` + +### URL Schemes + +```swift +let schemes = AppInfo.current.urlSchemes +// e.g. ["myapp"] from CFBundleURLTypes +``` + +### API Reference + +| Property | Type | Description | +|---|---|---| +| `bundleIdentifier` | `String?` | Bundle ID (iOS) or package name (Android) | +| `displayName` | `String?` | User-visible app name | +| `version` | `String?` | Version string (e.g. `"1.2.3"`) | +| `buildNumber` | `String?` | Build number as string | +| `buildNumberInt` | `Int?` | Build number as integer | +| `versionWithBuild` | `String` | Combined `"version (build)"` string | +| `isDebug` | `Bool` | Debug build (`DEBUG` flag on iOS, `FLAG_DEBUGGABLE` on Android) | +| `isRelease` | `Bool` | Release build (inverse of `isDebug`) | +| `isTestFlight` | `Bool` | TestFlight install (iOS only, always `false` on Android) | +| `osName` | `String` | Platform name (`"iOS"`, `"Android"`, `"macOS"`) | +| `osVersion` | `String` | OS version (e.g. `"17.4.1"` or `"34"` for API level) | +| `deviceModel` | `String` | Device model (e.g. `"iPhone15,2"`, `"Pixel 7"`) | +| `minimumOSVersion` | `String?` | Minimum required OS version | +| `urlSchemes` | `[String]` | Registered URL schemes (iOS only) | +| `infoDictionaryKeys` | `[String]` | All Info.plist keys (iOS only) | +| `infoDictionaryValue(forKey:)` | `Any?` | Raw Info.plist value lookup (iOS only) | + +> [!NOTE] +> **iOS implementation**: Reads from `Bundle.main.infoDictionary`, `ProcessInfo`, and `utsname` for device model. TestFlight detection uses the sandbox receipt URL. +> +> **Android implementation**: Reads from `PackageManager.getPackageInfo()`, `ApplicationInfo`, and `android.os.Build`. The `osVersion` returns the SDK API level (e.g. `"34"` for Android 14). The `isDebug` flag checks `ApplicationInfo.FLAG_DEBUGGABLE`. + +## DeviceInfo + +`DeviceInfo.current` provides information about the physical device, including screen dimensions, device type, battery status, network connectivity, and locale. + +### Screen Information + +```swift +import SkipKit + +let device = DeviceInfo.current + +print("Screen: \(device.screenWidth) x \(device.screenHeight) points") +print("Scale: \(device.screenScale)x") +``` + +### Device Type + +Determine whether the app is running on a phone, tablet, desktop, TV, or watch: + +```swift +switch DeviceInfo.current.deviceType { +case .phone: print("Phone") +case .tablet: print("Tablet") +case .desktop: print("Desktop") +case .tv: print("TV") +case .watch: print("Watch") +case .unknown: print("Unknown") +} + +// Convenience checks +if DeviceInfo.current.isTablet { + // Use tablet layout +} +``` + +On iOS, this uses `UIDevice.current.userInterfaceIdiom`. On Android, it uses the screen layout configuration (large/xlarge = tablet). + +### Device Model + +```swift +print("Manufacturer: \(DeviceInfo.current.manufacturer)") // "Apple" or "Google", "Samsung", etc. +print("Model: \(DeviceInfo.current.modelName)") // "iPhone15,2" or "Pixel 7" +``` + +### Battery + +```swift +if let level = DeviceInfo.current.batteryLevel { + print("Battery: \(Int(level * 100))%") +} + +switch DeviceInfo.current.batteryState { +case .charging: print("Charging") +case .full: print("Full") +case .unplugged: print("On battery") +case .unknown: print("Unknown") +} +``` + +On iOS, uses `UIDevice.current.batteryLevel` and `.batteryState`. On Android, uses `BatteryManager`. + +### Network Connectivity + +#### One-Shot Check + +For a single point-in-time check, use the synchronous properties: + +```swift +let status = DeviceInfo.current.networkStatus +switch status { +case .wifi: print("Connected via Wi-Fi") +case .cellular: print("Connected via cellular") +case .ethernet: print("Connected via Ethernet") +case .other: print("Connected (other)") +case .offline: print("No connection") +} + +// Convenience checks +if DeviceInfo.current.isOnline { + // Proceed with network request +} +if DeviceInfo.current.isOnWifi { + // Safe for large downloads +} +``` + +#### Live Monitoring with AsyncStream + +For live updates whenever connectivity changes, use `monitorNetwork()` which returns an `AsyncStream`. The stream emits an initial value immediately and then a new value each time the network status changes: + +```swift +struct ConnectivityView: View { + @State var status: NetworkStatus = .offline + + var body: some View { + VStack { + Text("Network: \(status.rawValue)") + Circle() + .fill(status == .offline ? Color.red : Color.green) + .frame(width: 20, height: 20) + } + .task { + for await newStatus in DeviceInfo.current.monitorNetwork() { + status = newStatus + } + } + } +} +``` + +You can also use it in non-UI code: + +```swift +func waitForConnectivity() async -> NetworkStatus { + for await status in DeviceInfo.current.monitorNetwork() { + if status != .offline { + return status + } + } + return .offline +} +``` + +Cancel the monitoring by cancelling the enclosing `Task`: + +```swift +let monitorTask = Task { + for await status in DeviceInfo.current.monitorNetwork() { + print("Status changed: \(status.rawValue)") + } +} + +// Later, stop monitoring: +monitorTask.cancel() +``` + +On iOS, `monitorNetwork()` uses `NWPathMonitor` for live path updates. On Android, it uses `ConnectivityManager.registerDefaultNetworkCallback` which receives `onAvailable`, `onLost`, and `onCapabilitiesChanged` callbacks. The callback is automatically unregistered when the stream is cancelled. + +> [!NOTE] +> **Android**: To query or monitor network status, your app needs the `android.permission.ACCESS_NETWORK_STATE` permission in `AndroidManifest.xml`: +> ```xml +> +> ``` + +### Locale + +```swift +print("Locale: \(DeviceInfo.current.localeIdentifier)") // e.g. "en_US" +print("Language: \(DeviceInfo.current.languageCode ?? "")") // e.g. "en" +print("Time zone: \(DeviceInfo.current.timeZoneIdentifier)") // e.g. "America/New_York" +``` + +### API Reference + +**Screen:** + +| Property | Type | Description | +|---|---|---| +| `screenWidth` | `Double` | Screen width in points | +| `screenHeight` | `Double` | Screen height in points | +| `screenScale` | `Double` | Pixels per point | + +**Device:** + +| Property | Type | Description | +|---|---|---| +| `deviceType` | `DeviceType` | `.phone`, `.tablet`, `.desktop`, `.tv`, `.watch`, `.unknown` | +| `isTablet` | `Bool` | Whether the device is a tablet | +| `isPhone` | `Bool` | Whether the device is a phone | +| `manufacturer` | `String` | Device manufacturer (`"Apple"` on iOS) | +| `modelName` | `String` | Model identifier (e.g. `"iPhone15,2"`, `"Pixel 7"`) | + +**Battery:** + +| Property | Type | Description | +|---|---|---| +| `batteryLevel` | `Double?` | Battery level 0.0–1.0, or `nil` if unavailable | +| `batteryState` | `BatteryState` | `.unplugged`, `.charging`, `.full`, `.unknown` | + +**Network:** + +| Property | Type | Description | +|---|---|---| +| `networkStatus` | `NetworkStatus` | One-shot connectivity check | +| `isOnline` | `Bool` | Has any network connectivity (one-shot) | +| `isOnWifi` | `Bool` | Connected via Wi-Fi (one-shot) | +| `isOnCellular` | `Bool` | Connected via cellular (one-shot) | +| `monitorNetwork()` | `AsyncStream` | Live connectivity updates | + +**Locale:** + +| Property | Type | Description | +|---|---|---| +| `localeIdentifier` | `String` | Current locale (e.g. `"en_US"`) | +| `languageCode` | `String?` | Language code (e.g. `"en"`) | +| `timeZoneIdentifier` | `String` | Time zone (e.g. `"America/New_York"`) | + ## PermissionManager The `PermissionManager` provides the ability to request device permissions. diff --git a/Sources/SkipKit/AppInfo.swift b/Sources/SkipKit/AppInfo.swift new file mode 100644 index 0000000..496e087 --- /dev/null +++ b/Sources/SkipKit/AppInfo.swift @@ -0,0 +1,297 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation + +#if SKIP +import android.content.pm.PackageManager +import android.content.pm.ApplicationInfo +import android.os.Build +#endif + +/// Provides information about the currently running application. +/// +/// Access via the `AppInfo.current` singleton. All properties are computed lazily +/// and cached for the lifetime of the process. +/// +/// On iOS, this reads from `Bundle.main.infoDictionary` and system APIs. +/// On Android, this reads from `PackageManager`, `ApplicationInfo`, and `Build`. +public struct AppInfo { + /// The shared instance for the currently running app. + nonisolated(unsafe) public static let current = AppInfo() + + private init() { } + + // MARK: - Identity + + /// The bundle identifier (iOS) or package name (Android). + /// + /// Example: `"com.example.myapp"` + public var appIdentifier: String? { + _appIdentifier + } + + /// The user-visible display name of the app. + /// + /// On iOS: `CFBundleDisplayName` or `CFBundleName`. + /// On Android: The application label from `PackageManager`. + public var displayName: String? { + _displayName + } + + // MARK: - Version + + /// The user-facing version string (e.g. `"1.2.3"`). + /// + /// On iOS: `CFBundleShortVersionString`. + /// On Android: `versionName` from `PackageInfo`. + public var version: String? { + _version + } + + /// The internal build number as a string (e.g. `"42"` or `"2024.03.15"`). + /// + /// On iOS: `CFBundleVersion`. + /// On Android: `versionCode` from `PackageInfo` (as a string). + public var buildNumber: String? { + _buildNumber + } + + /// The build number as an integer, if parseable. + /// + /// On iOS: `CFBundleVersion` parsed as Int. + /// On Android: `versionCode`. + public var buildNumberInt: Int? { + _buildNumberInt + } + + /// A combined "version (build)" string, e.g. `"1.2.3 (42)"`. + public var versionWithBuild: String { + let v = version ?? "0.0.0" + if let b = buildNumber { + return "\(v) (\(b))" + } + return v + } + + // MARK: - Build Configuration + + /// Whether the app is running in a debug build. + /// + /// On iOS: Checks for the `DEBUG` preprocessor flag. + /// On Android: Reads `ApplicationInfo.FLAG_DEBUGGABLE`. + public var isDebug: Bool { + _isDebug + } + + /// Whether the app is running in a release build (the inverse of `isDebug`). + public var isRelease: Bool { + !isDebug + } + + /// Whether the app was installed from TestFlight (iOS only). + /// Returns `false` on Android. + public var isTestFlight: Bool { + _isTestFlight + } + + // MARK: - Platform Info + + /// The operating system name (e.g. `"iOS"`, `"Android"`). + public var osName: String { + #if SKIP + "Android" + #elseif os(iOS) + "iOS" + #elseif os(macOS) + "macOS" + #elseif os(tvOS) + "tvOS" + #elseif os(watchOS) + "watchOS" + #else + "Unknown" + #endif + } + + /// The operating system version string. + /// + /// On iOS/macOS: e.g. `"17.4.1"`. + /// On Android: The SDK version string, e.g. `"14"` (API level 34). + public var osVersion: String { + _osVersion + } + + /// The device model identifier. + /// + /// On iOS: e.g. `"iPhone15,2"`. + /// On Android: e.g. `"Pixel 7"`. + public var deviceModel: String { + _deviceModel + } + + // MARK: - App Bundle Info (iOS-specific, safe no-ops on Android) + + /// The minimum OS version required by the app. + /// + /// On iOS: `MinimumOSVersion` from Info.plist. + /// On Android: `minSdkVersion` from `ApplicationInfo`. + public var minimumOSVersion: String? { + _minimumOSVersion + } + + /// The app's URL scheme types (iOS only). Returns an empty array on Android. + public var urlSchemes: [String] { + _urlSchemes + } + + /// All keys available in the iOS Info.plist. Returns an empty array on Android. + public var infoDictionaryKeys: [String] { + #if !SKIP + return Array((Bundle.main.infoDictionary ?? [:]).keys) + #else + return [] + #endif + } + + /// Access a raw Info.plist value by key (iOS) or returns `nil` on Android. + public func infoDictionaryValue(forKey key: String) -> Any? { + #if !SKIP + return Bundle.main.infoDictionary?[key] + #else + return nil + #endif + } +} + +// MARK: - Private Cached Values + +#if SKIP +private let _pkgInfo: android.content.pm.PackageInfo = { + let context = ProcessInfo.processInfo.androidContext + return context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_META_DATA) +}() + +private let _appInfo: ApplicationInfo = { + let context = ProcessInfo.processInfo.androidContext + return context.getApplicationInfo() +}() +#endif + +private let _appIdentifier: String? = { + #if !SKIP + Bundle.main.bundleIdentifier + #else + ProcessInfo.processInfo.androidContext.getPackageName() + #endif +}() + +private let _displayName: String? = { + #if !SKIP + (Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String) + ?? (Bundle.main.infoDictionary?["CFBundleName"] as? String) + #else + let context = ProcessInfo.processInfo.androidContext + let pm = context.getPackageManager() + let label = _appInfo.loadLabel(pm) + return "\(label)" + #endif +}() + +private let _version: String? = { + #if !SKIP + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + #else + _pkgInfo.versionName + #endif +}() + +private let _buildNumber: String? = { + #if !SKIP + Bundle.main.infoDictionary?["CFBundleVersion"] as? String + #else + "\(_pkgInfo.versionCode)" + #endif +}() + +private let _buildNumberInt: Int? = { + #if !SKIP + if let str = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { + return Int(str) + } + return nil + #else + Int(_pkgInfo.versionCode) + #endif +}() + +private let _isDebug: Bool = { + #if !SKIP + #if DEBUG + return true + #else + return false + #endif + #else + return (_appInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0 + #endif +}() + +private let _isTestFlight: Bool = { + #if !SKIP + #if targetEnvironment(simulator) + return false + #else + guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } + return receiptURL.lastPathComponent == "sandboxReceipt" + #endif + #else + return false + #endif +}() + +private let _osVersion: String = { + #if !SKIP + let v = ProcessInfo.processInfo.operatingSystemVersion + return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + #else + return "\(Build.VERSION.SDK_INT)" + #endif +}() + +private let _deviceModel: String = { + #if !SKIP + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) ?? "Unknown" + } + } + #else + return Build.MODEL + #endif +}() + +private let _minimumOSVersion: String? = { + #if !SKIP + return Bundle.main.infoDictionary?["MinimumOSVersion"] as? String + #else + return "\(_appInfo.minSdkVersion)" + #endif +}() + +private let _urlSchemes: [String] = { + #if !SKIP + guard let types = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]] else { + return [] + } + return types.compactMap { dict in + (dict["CFBundleURLSchemes"] as? [String])?.first + } + #else + return [] + #endif +}() + +#endif diff --git a/Sources/SkipKit/DeviceInfo.swift b/Sources/SkipKit/DeviceInfo.swift new file mode 100644 index 0000000..85b20bb --- /dev/null +++ b/Sources/SkipKit/DeviceInfo.swift @@ -0,0 +1,439 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation +import SwiftUI + +#if SKIP +import android.content.Context +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.util.DisplayMetrics +import android.view.WindowManager +#else +#if canImport(UIKit) +import UIKit +#endif +#if canImport(Network) +import Network +#endif +#if canImport(IOKit) +import IOKit.ps +#endif +#endif + +// MARK: - DeviceType + +/// The general category of the device. +public enum DeviceType: String, Sendable { + case phone + case tablet + case desktop + case tv + case watch + case unknown +} + +// MARK: - BatteryState + +/// The current charging state of the device battery. +public enum BatteryState: String, Sendable { + /// The device is not plugged in and running on battery. + case unplugged + /// The device is plugged in and charging. + case charging + /// The device is plugged in and the battery is full. + case full + /// The battery state is unknown. + case unknown +} + +// MARK: - NetworkStatus + +/// The current network connectivity status. +public enum NetworkStatus: String, Sendable { + /// The device has no network connectivity. + case offline + /// The device is connected via Wi-Fi. + case wifi + /// The device is connected via cellular data. + case cellular + /// The device is connected via Ethernet. + case ethernet + /// The device is connected via an unknown transport. + case other +} + +// MARK: - DeviceInfo + +/// Provides information about the current device, including screen size, device type, +/// battery status, and network connectivity. +/// +/// Access via the `DeviceInfo.current` singleton. +/// +/// On iOS, this reads from `UIDevice`, `UIScreen`, `NWPathMonitor`, and `ProcessInfo`. +/// On Android, this reads from `DisplayMetrics`, `BatteryManager`, `ConnectivityManager`, and `Build`. +public final class DeviceInfo { + nonisolated(unsafe) public static let current = DeviceInfo() + + private init() { } + + // MARK: - Screen + + /// The screen width in points. + public var screenWidth: Double { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let dm = context.getResources().getDisplayMetrics() + return Double(dm.widthPixels) / Double(dm.density) + #elseif os(iOS) + return Double(UIScreen.main.bounds.width) + #elseif os(macOS) + return Double(NSScreen.main?.frame.width ?? 0) + #else + return 0 + #endif + } + + /// The screen height in points. + public var screenHeight: Double { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let dm = context.getResources().getDisplayMetrics() + return Double(dm.heightPixels) / Double(dm.density) + #elseif os(iOS) + return Double(UIScreen.main.bounds.height) + #elseif os(macOS) + return Double(NSScreen.main?.frame.height ?? 0) + #else + return 0 + #endif + } + + /// The screen scale factor (pixels per point). + public var screenScale: Double { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + return Double(context.getResources().getDisplayMetrics().density) + #elseif os(iOS) + return Double(UIScreen.main.scale) + #elseif os(macOS) + return Double(NSScreen.main?.backingScaleFactor ?? 1.0) + #else + return 1.0 + #endif + } + + // MARK: - Device Type + + /// The general category of the current device. + /// + /// On iOS: uses `UIDevice.current.userInterfaceIdiom`. + /// On Android: uses screen size configuration (smallest width >= 600dp = tablet). + public var deviceType: DeviceType { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let config = context.getResources().getConfiguration() + let screenLayout = config.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK + if screenLayout >= Configuration.SCREENLAYOUT_SIZE_XLARGE { + return .tablet + } else if screenLayout >= Configuration.SCREENLAYOUT_SIZE_LARGE { + return .tablet + } else { + return .phone + } + #elseif os(iOS) + switch UIDevice.current.userInterfaceIdiom { + case .phone: return .phone + case .pad: return .tablet + case .tv: return .tv + case .mac: return .desktop + default: return .unknown + } + #elseif os(macOS) + return .desktop + #elseif os(tvOS) + return .tv + #elseif os(watchOS) + return .watch + #else + return .unknown + #endif + } + + /// Whether the device is likely a tablet (iPad or large-screen Android device). + public var isTablet: Bool { + deviceType == .tablet + } + + /// Whether the device is likely a phone. + public var isPhone: Bool { + deviceType == .phone + } + + // MARK: - Device Model + + /// The manufacturer of the device. + /// + /// On iOS: always `"Apple"`. + /// On Android: `Build.MANUFACTURER` (e.g. `"Google"`, `"Samsung"`). + public var manufacturer: String { + #if SKIP + return Build.MANUFACTURER + #else + return "Apple" + #endif + } + + /// The model name of the device. + /// + /// On iOS: the machine identifier (e.g. `"iPhone15,2"`). + /// On Android: `Build.MODEL` (e.g. `"Pixel 7"`). + public var modelName: String { + #if SKIP + return Build.MODEL + #elseif os(iOS) + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) ?? "Unknown" + } + } + #elseif os(macOS) + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) ?? "Unknown" + } + } + #else + return "Unknown" + #endif + } + + // MARK: - Battery + + /// The current battery level as a value from 0.0 to 1.0, or `nil` if unavailable. + /// + /// On iOS: uses `UIDevice.current.batteryLevel` (must enable monitoring). + /// On Android: uses `BatteryManager.EXTRA_LEVEL`. + public var batteryLevel: Double? { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let bm = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + guard let bm = bm else { return nil } + let level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + if level < 0 { return nil } + return Double(level) / 100.0 + #elseif os(iOS) + let device = UIDevice.current + let wasEnabled = device.isBatteryMonitoringEnabled + device.isBatteryMonitoringEnabled = true + let level = device.batteryLevel + if !wasEnabled { device.isBatteryMonitoringEnabled = false } + if level < 0 { return nil } + return Double(level) + #else + return nil + #endif + } + + /// The current battery charging state. + /// + /// On iOS: uses `UIDevice.current.batteryState`. + /// On Android: uses `BatteryManager.isCharging()` and battery property. + public var batteryState: BatteryState { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let bm = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + guard let bm = bm else { return .unknown } + if bm.isCharging() { + let level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + return level >= 100 ? .full : .charging + } + return .unplugged + #elseif os(iOS) + let device = UIDevice.current + let wasEnabled = device.isBatteryMonitoringEnabled + device.isBatteryMonitoringEnabled = true + let state = device.batteryState + if !wasEnabled { device.isBatteryMonitoringEnabled = false } + switch state { + case .unplugged: return .unplugged + case .charging: return .charging + case .full: return .full + case .unknown: return .unknown + @unknown default: return .unknown + } + #else + return .unknown + #endif + } + + // MARK: - Network + + /// A one-shot check of the current network connectivity status. + /// + /// For live updates, use `monitorNetwork()` instead. + /// + /// On iOS: uses `NWPathMonitor` for a single snapshot. + /// On Android: uses `ConnectivityManager` with `NetworkCapabilities`. + public var networkStatus: NetworkStatus { + #if SKIP + return Self.queryAndroidNetworkStatus() + #elseif canImport(Network) + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "skip.kit.network.snapshot") + var result: NetworkStatus = .offline + let semaphore = DispatchSemaphore(value: 0) + monitor.pathUpdateHandler = { path in + result = Self.mapNWPath(path) + semaphore.signal() + } + monitor.start(queue: queue) + _ = semaphore.wait(timeout: .now() + 1.0) + monitor.cancel() + return result + #else + return .offline + #endif + } + + /// Whether the device currently has network connectivity (one-shot check). + public var isOnline: Bool { + networkStatus != .offline + } + + /// Whether the device is connected via Wi-Fi (one-shot check). + public var isOnWifi: Bool { + networkStatus == .wifi + } + + /// Whether the device is connected via cellular data (one-shot check). + public var isOnCellular: Bool { + networkStatus == .cellular + } + + /// Returns an `AsyncStream` that emits `NetworkStatus` values whenever connectivity changes. + /// + /// The stream emits an initial value immediately, then a new value each time the + /// network status changes (e.g. Wi-Fi connected, cellular lost, etc.). + /// + /// Cancel the `for await` loop or the enclosing `Task` to stop monitoring. + /// + /// On iOS: uses `NWPathMonitor` for live path updates. + /// On Android: uses `ConnectivityManager.registerDefaultNetworkCallback`. + /// + /// ```swift + /// for await status in DeviceInfo.current.monitorNetwork() { + /// print("Network: \(status)") + /// } + /// ``` + public func monitorNetwork() -> AsyncStream { + AsyncStream { continuation in + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + + // Emit initial status + continuation.yield(Self.queryAndroidNetworkStatus()) + + guard let cm = cm else { + continuation.finish() + return + } + + /* SKIP INSERT: + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + continuation.yield(DeviceInfo.queryAndroidNetworkStatus()) + } + override fun onLost(network: android.net.Network) { + continuation.yield(NetworkStatus.offline) + } + override fun onCapabilitiesChanged(network: android.net.Network, caps: NetworkCapabilities) { + continuation.yield(DeviceInfo.queryAndroidNetworkStatus()) + } + } + */ + + cm.registerDefaultNetworkCallback(callback) + + continuation.onTermination = { _ in + // SKIP INSERT: cm.unregisterNetworkCallback(callback) + } + + #elseif canImport(Network) + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "skip.kit.network.monitor") + monitor.pathUpdateHandler = { path in + continuation.yield(Self.mapNWPath(path)) + } + monitor.start(queue: queue) + + continuation.onTermination = { _ in + monitor.cancel() + } + #else + continuation.yield(.offline) + continuation.finish() + #endif + } + } + + // MARK: - Network Helpers + + #if SKIP + private static func queryAndroidNetworkStatus() -> NetworkStatus { + let context = ProcessInfo.processInfo.androidContext + let cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + guard let cm = cm else { return .offline } + let network = cm.getActiveNetwork() + guard let network = network else { return .offline } + let caps = cm.getNetworkCapabilities(network) + guard let caps = caps else { return .offline } + if caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) { return .wifi } + if caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) { return .cellular } + if caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) { return .ethernet } + return .other + } + #endif + + #if canImport(Network) && !SKIP + private static func mapNWPath(_ path: NWPath) -> NetworkStatus { + guard path.status == .satisfied else { return .offline } + if path.usesInterfaceType(.wifi) { return .wifi } + if path.usesInterfaceType(.cellular) { return .cellular } + if path.usesInterfaceType(.wiredEthernet) { return .ethernet } + return .other + } + #endif + + // MARK: - Locale + + /// The user's current locale identifier (e.g. `"en_US"`). + public var localeIdentifier: String { + Locale.current.identifier + } + + /// The user's preferred language code (e.g. `"en"`). + public var languageCode: String? { + #if SKIP + return Locale.current.language.languageCode?.identifier + #else + return Locale.current.language.languageCode?.identifier + #endif + } + + /// The user's current time zone identifier (e.g. `"America/New_York"`). + public var timeZoneIdentifier: String { + TimeZone.current.identifier + } +} + +#endif diff --git a/Tests/SkipKitTests/SkipKitTests.swift b/Tests/SkipKitTests/SkipKitTests.swift index 7fb85b3..5a33e5d 100644 --- a/Tests/SkipKitTests/SkipKitTests.swift +++ b/Tests/SkipKitTests/SkipKitTests.swift @@ -92,4 +92,196 @@ final class SkipKitTests: XCTestCase { XCTAssertEqual(MailComposerResult.sent.rawValue, "sent") XCTAssertEqual(MailComposerResult.cancelled.rawValue, "cancelled") } + + // MARK: - AppInfo Tests + + func testAppInfoSingleton() throws { + let info = AppInfo.current + // Should always be accessible + XCTAssertNotNil(info) + } + + func testAppInfoOSName() throws { + let name = AppInfo.current.osName + XCTAssertFalse(name.isEmpty) + // On macOS test runner, should be "macOS"; on Android, "Android" + #if os(macOS) && !SKIP + XCTAssertEqual(name, "macOS") + #endif + } + + func testAppInfoOSVersion() throws { + let version = AppInfo.current.osVersion + XCTAssertFalse(version.isEmpty) + } + + func testAppInfoDeviceModel() throws { + let model = AppInfo.current.deviceModel + XCTAssertFalse(model.isEmpty) + } + + func testAppInfoBuildConfiguration() throws { + // isDebug and isRelease should be inverses + XCTAssertNotEqual(AppInfo.current.isDebug, AppInfo.current.isRelease) + } + + func testAppInfoVersionWithBuild() throws { + let vwb = AppInfo.current.versionWithBuild + XCTAssertFalse(vwb.isEmpty) + // Should always contain at least a version + XCTAssertTrue(vwb.count >= 1) + } + + func testAppInfoDisplayName() throws { + // Display name may or may not be set depending on test context + // Just verify the accessor doesn't crash + let _ = AppInfo.current.displayName + } + + func testAppInfoAppIdentifier() throws { + // In a test context this may return the test bundle identifier or nil + let _ = AppInfo.current.appIdentifier + } + + func testAppInfoTestFlight() throws { + // In test context, should not be TestFlight + #if !SKIP + XCTAssertFalse(AppInfo.current.isTestFlight) + #endif + } + + func testAppInfoMinimumOS() throws { + let _ = AppInfo.current.minimumOSVersion + } + + func testAppInfoURLSchemes() throws { + let schemes = AppInfo.current.urlSchemes + // Should return an array (possibly empty) + XCTAssertTrue(schemes.count >= 0) + } + + func testAppInfoDictionaryAccess() throws { + // On iOS, should have keys; on Android returns empty + let keys = AppInfo.current.infoDictionaryKeys + XCTAssertTrue(keys.count >= 0) + + // Should not crash for any key + let _ = AppInfo.current.infoDictionaryValue(forKey: "CFBundleIdentifier") + let _ = AppInfo.current.infoDictionaryValue(forKey: "NonExistentKey") + } + + // MARK: - DeviceInfo Tests + + func testDeviceInfoSingleton() throws { + let info = DeviceInfo.current + XCTAssertNotNil(info) + } + + func testDeviceInfoScreenDimensions() throws { + let width = DeviceInfo.current.screenWidth + let height = DeviceInfo.current.screenHeight + let scale = DeviceInfo.current.screenScale + // In test context (macOS/Robolectric), dimensions may be zero but shouldn't be negative + XCTAssertTrue(width >= 0) + XCTAssertTrue(height >= 0) + XCTAssertTrue(scale > 0) + } + + func testDeviceInfoDeviceType() throws { + let deviceType = DeviceInfo.current.deviceType + // Should return a valid enum case + let validTypes: [DeviceType] = [.phone, .tablet, .desktop, .tv, .watch, .unknown] + XCTAssertTrue(validTypes.contains(deviceType)) + + // isTablet and isPhone should be consistent with deviceType + if DeviceInfo.current.isTablet { + XCTAssertEqual(deviceType, .tablet) + } + if DeviceInfo.current.isPhone { + XCTAssertEqual(deviceType, .phone) + } + } + + func testDeviceInfoManufacturer() throws { + let manufacturer = DeviceInfo.current.manufacturer + XCTAssertFalse(manufacturer.isEmpty) + #if os(macOS) && !SKIP + XCTAssertEqual(manufacturer, "Apple") + #endif + } + + func testDeviceInfoModelName() throws { + let model = DeviceInfo.current.modelName + XCTAssertFalse(model.isEmpty) + } + + func testDeviceInfoBattery() throws { + // Battery may be nil in test/simulator context + let _ = DeviceInfo.current.batteryLevel + + let state = DeviceInfo.current.batteryState + let validStates: [BatteryState] = [.unplugged, .charging, .full, .unknown] + XCTAssertTrue(validStates.contains(state)) + } + + func testDeviceInfoNetworkStatus() throws { + let status = DeviceInfo.current.networkStatus + let validStatuses: [NetworkStatus] = [.offline, .wifi, .cellular, .ethernet, .other] + XCTAssertTrue(validStatuses.contains(status)) + + // isOnline should be consistent + if status != .offline { + XCTAssertTrue(DeviceInfo.current.isOnline) + } + } + + func testDeviceInfoNetworkConvenience() throws { + let _ = DeviceInfo.current.isOnline + let _ = DeviceInfo.current.isOnWifi + let _ = DeviceInfo.current.isOnCellular + } + + func testDeviceInfoMonitorNetwork() async throws { + // Verify the stream can be created and yields at least one value + var received = false + let stream = DeviceInfo.current.monitorNetwork() + for await status in stream { + let validStatuses: [NetworkStatus] = [.offline, .wifi, .cellular, .ethernet, .other] + XCTAssertTrue(validStatuses.contains(status)) + received = true + break // Just check the first emitted value + } + XCTAssertTrue(received, "monitorNetwork should emit at least one initial value") + } + + func testDeviceInfoLocale() throws { + let locale = DeviceInfo.current.localeIdentifier + XCTAssertFalse(locale.isEmpty) + + let tz = DeviceInfo.current.timeZoneIdentifier + XCTAssertFalse(tz.isEmpty) + + // Language code may or may not be nil + let _ = DeviceInfo.current.languageCode + } + + func testDeviceTypeEnum() throws { + let types: [DeviceType] = [.phone, .tablet, .desktop, .tv, .watch, .unknown] + XCTAssertEqual(types.count, 6) + XCTAssertEqual(DeviceType.phone.rawValue, "phone") + XCTAssertEqual(DeviceType.tablet.rawValue, "tablet") + } + + func testBatteryStateEnum() throws { + let states: [BatteryState] = [.unplugged, .charging, .full, .unknown] + XCTAssertEqual(states.count, 4) + XCTAssertEqual(BatteryState.charging.rawValue, "charging") + } + + func testNetworkStatusEnum() throws { + let statuses: [NetworkStatus] = [.offline, .wifi, .cellular, .ethernet, .other] + XCTAssertEqual(statuses.count, 5) + XCTAssertEqual(NetworkStatus.wifi.rawValue, "wifi") + XCTAssertEqual(NetworkStatus.offline.rawValue, "offline") + } } From ba7590b41e36b308d9441ce15a6b315da68aff74 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 31 Mar 2026 16:07:52 -0400 Subject: [PATCH 2/2] Update to check for canImport(UIKit) rather than os(iOS) to support tvOS and other Darwin platforms --- Sources/SkipKit/DeviceInfo.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/SkipKit/DeviceInfo.swift b/Sources/SkipKit/DeviceInfo.swift index 85b20bb..be22020 100644 --- a/Sources/SkipKit/DeviceInfo.swift +++ b/Sources/SkipKit/DeviceInfo.swift @@ -89,7 +89,7 @@ public final class DeviceInfo { let context = ProcessInfo.processInfo.androidContext let dm = context.getResources().getDisplayMetrics() return Double(dm.widthPixels) / Double(dm.density) - #elseif os(iOS) + #elseif canImport(UIKit) return Double(UIScreen.main.bounds.width) #elseif os(macOS) return Double(NSScreen.main?.frame.width ?? 0) @@ -104,7 +104,7 @@ public final class DeviceInfo { let context = ProcessInfo.processInfo.androidContext let dm = context.getResources().getDisplayMetrics() return Double(dm.heightPixels) / Double(dm.density) - #elseif os(iOS) + #elseif canImport(UIKit) return Double(UIScreen.main.bounds.height) #elseif os(macOS) return Double(NSScreen.main?.frame.height ?? 0) @@ -118,7 +118,7 @@ public final class DeviceInfo { #if SKIP let context = ProcessInfo.processInfo.androidContext return Double(context.getResources().getDisplayMetrics().density) - #elseif os(iOS) + #elseif canImport(UIKit) return Double(UIScreen.main.scale) #elseif os(macOS) return Double(NSScreen.main?.backingScaleFactor ?? 1.0) @@ -145,7 +145,7 @@ public final class DeviceInfo { } else { return .phone } - #elseif os(iOS) + #elseif canImport(UIKit) switch UIDevice.current.userInterfaceIdiom { case .phone: return .phone case .pad: return .tablet @@ -230,7 +230,7 @@ public final class DeviceInfo { let level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) if level < 0 { return nil } return Double(level) / 100.0 - #elseif os(iOS) + #elseif canImport(UIKit) let device = UIDevice.current let wasEnabled = device.isBatteryMonitoringEnabled device.isBatteryMonitoringEnabled = true @@ -257,7 +257,7 @@ public final class DeviceInfo { return level >= 100 ? .full : .charging } return .unplugged - #elseif os(iOS) + #elseif canImport(UIKit) let device = UIDevice.current let wasEnabled = device.isBatteryMonitoringEnabled device.isBatteryMonitoringEnabled = true