From 306642a1009ff9336b8ae690cff3d7724c635f45 Mon Sep 17 00:00:00 2001 From: Mihai Seremet Date: Fri, 26 Jan 2024 23:13:25 +0000 Subject: [PATCH] Initial implementation --- .github/workflows/ci.yml | 62 +++ .gitignore | 7 +- .swiftlint.yml | 8 + Example/Example.xcodeproj/project.pbxproj | 376 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Example.xcscheme | 77 ++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 63 +++ Example/Example/Assets.xcassets/Contents.json | 6 + Example/Example/ContentView.swift | 79 ++++ Example/Example/ExampleApp.swift | 10 + Makefile | 62 +++ Package.swift | 31 ++ README.md | 126 +++++- Sources/Connection/ConnectionStatus.swift | 31 ++ Sources/Connection/ConnectionType.swift | 51 +++ Sources/Connection/DisconnectedReason.swift | 32 ++ Sources/Extensions/AsyncStream.swift | 15 + Sources/PathMonitor/InterfaceType.swift | 7 + Sources/PathMonitor/PathMonitorType.swift | 47 +++ Sources/PathMonitor/PathType.swift | 12 + Sources/PathMonitor/TelephonyInfoType.swift | 70 ++++ Sources/Reachability/Reachability.swift | 58 +++ Sources/Resources/PrivacyInfo.xcprivacy | 8 + .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcschemes/SwiftReachability.xcscheme | 77 ++++ Tests/ConnectionStatusTests.swift | 44 ++ Tests/ConnectionTypeTests.swift | 76 ++++ Tests/Helpers/FuncCheck.swift | 20 + Tests/Mocks/MockPath.swift | 30 ++ Tests/Mocks/MockPathMonitor.swift | 38 ++ Tests/Mocks/MockTelephonyInfo.swift | 48 +++ Tests/PathMonitorTests.swift | 67 ++++ Tests/ReachabilityTests.swift | 167 ++++++++ Tests/TelephonyInfoTests.swift | 70 ++++ 37 files changed, 1915 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swiftlint.yml create mode 100644 Example/Example.xcodeproj/project.pbxproj create mode 100644 Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme create mode 100644 Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/Example/Assets.xcassets/Contents.json create mode 100644 Example/Example/ContentView.swift create mode 100644 Example/Example/ExampleApp.swift create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 Sources/Connection/ConnectionStatus.swift create mode 100644 Sources/Connection/ConnectionType.swift create mode 100644 Sources/Connection/DisconnectedReason.swift create mode 100644 Sources/Extensions/AsyncStream.swift create mode 100644 Sources/PathMonitor/InterfaceType.swift create mode 100644 Sources/PathMonitor/PathMonitorType.swift create mode 100644 Sources/PathMonitor/PathType.swift create mode 100644 Sources/PathMonitor/TelephonyInfoType.swift create mode 100644 Sources/Reachability/Reachability.swift create mode 100644 Sources/Resources/PrivacyInfo.xcprivacy create mode 100644 SwiftReachability.xcworkspace/contents.xcworkspacedata create mode 100644 SwiftReachability.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 SwiftReachability.xcworkspace/xcshareddata/xcschemes/SwiftReachability.xcscheme create mode 100644 Tests/ConnectionStatusTests.swift create mode 100644 Tests/ConnectionTypeTests.swift create mode 100644 Tests/Helpers/FuncCheck.swift create mode 100644 Tests/Mocks/MockPath.swift create mode 100644 Tests/Mocks/MockPathMonitor.swift create mode 100644 Tests/Mocks/MockTelephonyInfo.swift create mode 100644 Tests/PathMonitorTests.swift create mode 100644 Tests/ReachabilityTests.swift create mode 100644 Tests/TelephonyInfoTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c7d0de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: brew install swiftlint + + - name: Run SwiftLint + run: make lint + + build: + name: Build + runs-on: macos-14 + strategy: + matrix: + config: ['debug', 'release'] + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: gem install xcpretty + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Build platforms in ${{ matrix.config }} + run: make CONFIG=${{ matrix.config }} build-all-platforms + + test: + name: Test + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: gem install xcpretty + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Run tests + run: make CONFIG=debug test-all-platforms diff --git a/.gitignore b/.gitignore index 330d167..d0ed324 100644 --- a/.gitignore +++ b/.gitignore @@ -37,16 +37,15 @@ playground.xcworkspace # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ # Package.pins # Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - +.swiftpm/ .build/ +Packages/ # CocoaPods # @@ -88,3 +87,5 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +.DS_Store diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..d8d66a4 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,8 @@ +included: + - Sources + - Tests + - Example + - Package.swift + - Package@swift-5.9.swift +excluded: + - .build diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..75d36a4 --- /dev/null +++ b/Example/Example.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 995EF5ED2B815F17000B5E39 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995EF5EC2B815F17000B5E39 /* ExampleApp.swift */; }; + 995EF5EF2B815F17000B5E39 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995EF5EE2B815F17000B5E39 /* ContentView.swift */; }; + 995EF5F12B815F1C000B5E39 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 995EF5F02B815F1C000B5E39 /* Assets.xcassets */; }; + 995EF5FD2B815FF8000B5E39 /* SwiftReachability in Frameworks */ = {isa = PBXBuildFile; productRef = 995EF5FC2B815FF8000B5E39 /* SwiftReachability */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 995EF5E92B815F17000B5E39 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 995EF5EC2B815F17000B5E39 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; + 995EF5EE2B815F17000B5E39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 995EF5F02B815F1C000B5E39 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 995EF5E62B815F17000B5E39 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 995EF5FD2B815FF8000B5E39 /* SwiftReachability in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 995EF5E02B815F17000B5E39 = { + isa = PBXGroup; + children = ( + 995EF5EB2B815F17000B5E39 /* Example */, + 995EF5EA2B815F17000B5E39 /* Products */, + ); + sourceTree = ""; + }; + 995EF5EA2B815F17000B5E39 /* Products */ = { + isa = PBXGroup; + children = ( + 995EF5E92B815F17000B5E39 /* Example.app */, + ); + name = Products; + sourceTree = ""; + }; + 995EF5EB2B815F17000B5E39 /* Example */ = { + isa = PBXGroup; + children = ( + 995EF5EC2B815F17000B5E39 /* ExampleApp.swift */, + 995EF5EE2B815F17000B5E39 /* ContentView.swift */, + 995EF5F02B815F1C000B5E39 /* Assets.xcassets */, + ); + path = Example; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 995EF5E82B815F17000B5E39 /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 995EF5F82B815F1C000B5E39 /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + 995EF5E52B815F17000B5E39 /* Sources */, + 995EF5E62B815F17000B5E39 /* Frameworks */, + 995EF5E72B815F17000B5E39 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Example; + packageProductDependencies = ( + 995EF5FC2B815FF8000B5E39 /* SwiftReachability */, + ); + productName = Example; + productReference = 995EF5E92B815F17000B5E39 /* Example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 995EF5E12B815F17000B5E39 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1520; + LastUpgradeCheck = 1520; + TargetAttributes = { + 995EF5E82B815F17000B5E39 = { + CreatedOnToolsVersion = 15.2; + }; + }; + }; + buildConfigurationList = 995EF5E42B815F17000B5E39 /* Build configuration list for PBXProject "Example" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 995EF5E02B815F17000B5E39; + packageReferences = ( + 995EF5FB2B815FF8000B5E39 /* XCLocalSwiftPackageReference ".." */, + ); + productRefGroup = 995EF5EA2B815F17000B5E39 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 995EF5E82B815F17000B5E39 /* Example */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 995EF5E72B815F17000B5E39 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 995EF5F12B815F1C000B5E39 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 995EF5E52B815F17000B5E39 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 995EF5EF2B815F17000B5E39 /* ContentView.swift in Sources */, + 995EF5ED2B815F17000B5E39 /* ExampleApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 995EF5F62B815F1C000B5E39 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 995EF5F72B815F1C000B5E39 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 995EF5F92B815F1C000B5E39 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 9XTDAT7GE2; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 13.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mseremet.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3,7"; + }; + name = Debug; + }; + 995EF5FA2B815F1C000B5E39 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 9XTDAT7GE2; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 13.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mseremet.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3,7"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 995EF5E42B815F17000B5E39 /* Build configuration list for PBXProject "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 995EF5F62B815F1C000B5E39 /* Debug */, + 995EF5F72B815F1C000B5E39 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 995EF5F82B815F1C000B5E39 /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 995EF5F92B815F1C000B5E39 /* Debug */, + 995EF5FA2B815F1C000B5E39 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 995EF5FB2B815FF8000B5E39 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 995EF5FC2B815FF8000B5E39 /* SwiftReachability */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftReachability; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 995EF5E12B815F17000B5E39 /* Project object */; +} diff --git a/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..f037e3b --- /dev/null +++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..532cd72 --- /dev/null +++ b/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,63 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Assets.xcassets/Contents.json b/Example/Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/ContentView.swift b/Example/Example/ContentView.swift new file mode 100644 index 0000000..245e217 --- /dev/null +++ b/Example/Example/ContentView.swift @@ -0,0 +1,79 @@ +import SwiftUI +import SwiftReachability + +struct ContentView: View { + @ObservedObject private var reachability = Reachability.shared + + var body: some View { + Grid(horizontalSpacing: 48) { + if let image = reachability.status.icon { + GridRow { + Text("Status") + .gridColumnAlignment(.leading) + image + .fontWeight(.bold) + .foregroundStyle(reachability.status.isConnected ? Color.green : Color.red) + .gridColumnAlignment(.trailing) + } + Divider() + } + + GridRow { + Text(reachability.status.isConnected ? "Connected via" : "Reason") + .gridColumnAlignment(.leading) + Text(reachability.status.description) + .fontWeight(.bold) + .gridColumnAlignment(.trailing) + } + Divider() + + GridRow { + Text("Expensive") + .gridColumnAlignment(.leading) + Text(reachability.isExpensive ? "Yes" : "No") + .fontWeight(.bold) + .foregroundStyle(reachability.isExpensive ? Color.red : Color.green) + .gridColumnAlignment(.trailing) + } + Divider() + + GridRow { + Text("Constrained") + .gridColumnAlignment(.leading) + Text(reachability.isConstrained ? "Yes" : "No") + .fontWeight(.bold) + .foregroundStyle(reachability.isConstrained ? Color.red : Color.green) + .gridColumnAlignment(.trailing) + } + } + .fixedSize() + .padding() + } +} + +extension ConnectionType { + var icon: Image? { + switch self { + #if os(iOS) + case .cellular: Image(systemName: "cellularbars") + #endif + case .wifi: Image(systemName: "wifi") + case .wiredEthernet: Image(systemName: "cable.connector") + case .loopback: Image(systemName: "point.forward.to.point.capsulepath") + case .unknown: nil + } + } +} + +extension ConnectionStatus { + var icon: Image? { + switch self { + case .connected(let connectionType): connectionType.icon + case .disconnected: Image(systemName: "network.slash") + } + } +} + +#Preview { + ContentView() +} diff --git a/Example/Example/ExampleApp.swift b/Example/Example/ExampleApp.swift new file mode 100644 index 0000000..7e69ce8 --- /dev/null +++ b/Example/Example/ExampleApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ca4d286 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +NAME = SwiftReachability +CONFIG = debug + +GENERIC_PLATFORM_IOS = generic/platform=iOS +GENERIC_PLATFORM_MACOS = platform=macOS +GENERIC_PLATFORM_MAC_CATALYST = platform=macOS,variant=Mac Catalyst +GENERIC_PLATFORM_TVOS = generic/platform=tvOS +GENERIC_PLATFORM_WATCHOS = generic/platform=watchOS +GENERIC_PLATFORM_VISIONOS = generic/platform=visionOS + +SIM_PLATFORM_IOS = platform=iOS Simulator,id=$(call udid_for,iOS,iPhone \d\+ Pro [^M]) +SIM_PLATFORM_MACOS = platform=macOS +SIM_PLATFORM_MAC_CATALYST = platform=macOS,variant=Mac Catalyst +SIM_PLATFORM_TVOS = platform=tvOS Simulator,id=$(call udid_for,tvOS,TV) +SIM_PLATFORM_WATCHOS = platform=watchOS Simulator,id=$(call udid_for,watchOS,Watch) +SIM_PLATFORM_VISIONOS = platform=visionOS Simulator,id=$(call udid_for,visionOS,Vision) + +GREEN='\033[0;32m' +NC='\033[0m' + +build-all-platforms: + for platform in \ + "$(GENERIC_PLATFORM_IOS)" \ + "$(GENERIC_PLATFORM_MACOS)" \ + "$(GENERIC_PLATFORM_MAC_CATALYST)" \ + "$(GENERIC_PLATFORM_TVOS)" \ + "$(GENERIC_PLATFORM_WATCHOS)" \ + "$(GENERIC_PLATFORM_VISIONOS)"; \ + do \ + echo -e "\n${GREEN}Building $$platform ${NC}"\n; \ + set -o pipefail && xcrun xcodebuild build \ + -workspace $(NAME).xcworkspace \ + -scheme $(NAME) \ + -configuration $(CONFIG) \ + -destination "$$platform" | xcpretty || exit 1; \ + done; + +test-all-platforms: + for platform in \ + "$(SIM_PLATFORM_IOS)" \ + "$(SIM_PLATFORM_MACOS)" \ + "$(SIM_PLATFORM_MAC_CATALYST)" \ + "$(SIM_PLATFORM_TVOS)" \ + "$(SIM_PLATFORM_WATCHOS)" \ + "$(SIM_PLATFORM_VISIONOS)"; \ + do \ + echo -e "\n${GREEN}Testing $$platform ${NC}\n"; \ + set -o pipefail && xcrun xcodebuild test \ + -workspace $(NAME).xcworkspace \ + -scheme $(NAME) \ + -configuration $(CONFIG) \ + -destination "$$platform" | xcpretty || exit 1; \ + done; + +lint: + swiftlint lint --strict + +.PHONY: build-all-platforms test-all-platforms lint + +define udid_for +$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') +endef diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..352aa02 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "swift-reachability", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8), + .visionOS(.v1) + ], + products: [ + .library( + name: "SwiftReachability", + targets: ["SwiftReachability"] + ) + ], + targets: [ + .target( + name: "SwiftReachability", + path: "Sources", + resources: [.copy("Resources/PrivacyInfo.xcprivacy")] + ), + .testTarget( + name: "SwiftReachabilityTests", + dependencies: ["SwiftReachability"], + path: "Tests" + ) + ] +) diff --git a/README.md b/README.md index b58b90e..b925744 100644 --- a/README.md +++ b/README.md @@ -1 +1,125 @@ -# swift-reachability \ No newline at end of file + +# SwiftReachability + +Network reachability based on Apple's [`NWPathMonitor`](https://developer.apple.com/documentation/network/nwpathmonitor). + +[![CI](https://github.com/mihai8804858/swift-reachability/actions/workflows/ci.yml/badge.svg)](https://github.com/mihai8804858/swift-reachability/actions/workflows/ci.yml) + + +## Installation + +You can add `swift-reachability` to an Xcode project by adding it to your project as a package. + +> https://github.com/mihai8804858/swift-reachability + +If you want to use `swift-reachability` in a [SwiftPM](https://swift.org/package-manager/) project, it's as +simple as adding it to your `Package.swift`: + +``` swift +dependencies: [ + .package(url: "https://github.com/mihai8804858/swift-reachability", from: "1.0.0") +] +``` + +And then adding the product to any target that needs access to the library: + +```swift +.product(name: "SwiftReachability", package: "swift-reachability"), +``` + +## Quick Start + +* Create an instance of `Reachability`, or use the provided `Reachability.shared` instance: +```swift +private let reachability = Reachability.shared +``` +* Check the connection status: +```swift +let isConnected = reachability.status.isConnected +``` + +```swift +let isDisconnected = reachability.status.isDisconnected +``` + +```swift +switch reachability.status { +case .connected(let connectionType): + ... +case .disconnected(let reason): + ... +} +``` + +* Check the connection type: + +```swift +switch reachability.status.connectionType { +#if os(iOS) +case .cellular(_): + ... +#endif +case .wifi: + ... +case .wiredEthernet: + ... +case .loopback: + ... +case .unknown: + ... +} +``` + +See [ConnectionStatus](Sources/Connection/ConnectionStatus.swift), [ConnectionType](Sources/Connection/ConnectionType.swift) and [DisconnectedReason](Sources/Connection/DisconnectedReason.swift) for more info. + +* Check network constraints: + +Whether the path uses an interface that is considered expensive, such as Cellular or a Personal Hotspot. +```swift +let isExpensive = reachability.isExpensive +``` + +Whether the path uses an interface in Low Data Mode. +```swift +let isConstrained = reachability.isConstrained +``` + +* Listen for changes: + +```swift +for await status in networkReachability.changes() { + ... +} +``` + +```swift +for await isExpensive in networkReachability.expensiveChanges() { + ... +} +``` + +```swift +for await isConstrained in networkReachability.constrainedChanges() { + ... +} +``` + +* SwiftUI Support + +`Reachability` conforms to `ObservableObject` so it can be easily integrated into SwiftUI `View` and automatically update the UI when status changes: +```swift +struct ContentView: View { + @ObservedObject private var reachability = Reachability.shared + + var body: some View { + switch reachability.status { + case .connected: Text("Connected") + case .disconnected: Text("Disconnected") + } + } +} +``` + +## License + +This library is released under the MIT license. See [LICENSE](LICENSE) for details. diff --git a/Sources/Connection/ConnectionStatus.swift b/Sources/Connection/ConnectionStatus.swift new file mode 100644 index 0000000..23c6831 --- /dev/null +++ b/Sources/Connection/ConnectionStatus.swift @@ -0,0 +1,31 @@ +public enum ConnectionStatus: Hashable, CustomStringConvertible, Sendable { + case connected(ConnectionType) + case disconnected(DisconnectedReason) + + public var isConnected: Bool { + guard case .connected = self else { return false } + return true + } + + public var connectionType: ConnectionType? { + guard case .connected(let type) = self else { return nil } + return type + } + + public var isDisconnected: Bool { + guard case .disconnected = self else { return false } + return true + } + + public var disconnectedReason: DisconnectedReason? { + guard case .disconnected(let reason) = self else { return nil } + return reason + } + + public var description: String { + switch self { + case .connected(let connectionType): connectionType.description + case .disconnected(let reason): reason.description + } + } +} diff --git a/Sources/Connection/ConnectionType.swift b/Sources/Connection/ConnectionType.swift new file mode 100644 index 0000000..d10c051 --- /dev/null +++ b/Sources/Connection/ConnectionType.swift @@ -0,0 +1,51 @@ +public enum ConnectionType: Hashable, CustomStringConvertible, Sendable { + #if os(iOS) + public enum Cellular: Hashable, CustomStringConvertible, Sendable { + case cellular2G + case cellular3G + case cellular4G + case cellular5G + case other + + public var description: String { + switch self { + case .cellular2G: "Cellular 2G" + case .cellular3G: "Cellular 3G" + case .cellular4G: "Cellular 4G" + case .cellular5G: "Cellular 5G" + case .other: "Cellular" + } + } + } + + case cellular(Cellular) + #endif + case wifi + case wiredEthernet + case loopback + case unknown + + #if os(iOS) + public var isCellular: Bool { + guard case .cellular = self else { return false } + return true + } + #endif + + public var isWifi: Bool { self == .wifi } + public var isWiredEthernet: Bool { self == .wiredEthernet } + public var isLoopback: Bool { self == .loopback } + public var isUnknown: Bool { self == .unknown } + + public var description: String { + switch self { + #if os(iOS) + case .cellular(let cellular): cellular.description + #endif + case .wifi: "Wi-Fi" + case .wiredEthernet: "Ethernet" + case .loopback: "Loopback" + case .unknown: "Unknown" + } + } +} diff --git a/Sources/Connection/DisconnectedReason.swift b/Sources/Connection/DisconnectedReason.swift new file mode 100644 index 0000000..a259df9 --- /dev/null +++ b/Sources/Connection/DisconnectedReason.swift @@ -0,0 +1,32 @@ +import Network + +public enum DisconnectedReason: Hashable, CustomStringConvertible, Sendable { + case notAvailable + case cellularDenied + case wifiDenied + case localNetworkDenied + case vpnInactive + + public var description: String { + switch self { + case .notAvailable: "Unknown Reason" + case .cellularDenied: "Cellular Denied" + case .wifiDenied: "Wifi Denied" + case .localNetworkDenied: "Local Network Denied" + case .vpnInactive: "VPN Inactive" + } + } +} + +extension NWPath.UnsatisfiedReason { + var disconnectedReason: DisconnectedReason { + switch self { + case .notAvailable: .notAvailable + case .cellularDenied: .cellularDenied + case .wifiDenied: .wifiDenied + case .localNetworkDenied: .localNetworkDenied + case .vpnInactive: .vpnInactive + @unknown default: .notAvailable + } + } +} diff --git a/Sources/Extensions/AsyncStream.swift b/Sources/Extensions/AsyncStream.swift new file mode 100644 index 0000000..6b95cb0 --- /dev/null +++ b/Sources/Extensions/AsyncStream.swift @@ -0,0 +1,15 @@ +extension AsyncSequence { + public func eraseToStream() -> AsyncStream { + AsyncStream(self) + } +} + +extension AsyncStream { + public init(_ sequence: S) where S.Element == Element { + var iterator: S.AsyncIterator? + self.init { + if iterator == nil { iterator = sequence.makeAsyncIterator() } + return try? await iterator?.next() + } + } +} diff --git a/Sources/PathMonitor/InterfaceType.swift b/Sources/PathMonitor/InterfaceType.swift new file mode 100644 index 0000000..ff04dba --- /dev/null +++ b/Sources/PathMonitor/InterfaceType.swift @@ -0,0 +1,7 @@ +import Network + +protocol InterfaceType { + var type: NWInterface.InterfaceType { get } +} + +extension NWInterface: InterfaceType {} diff --git a/Sources/PathMonitor/PathMonitorType.swift b/Sources/PathMonitor/PathMonitorType.swift new file mode 100644 index 0000000..59f967b --- /dev/null +++ b/Sources/PathMonitor/PathMonitorType.swift @@ -0,0 +1,47 @@ +import Network +#if os(iOS) +import CoreTelephony +#endif + +protocol PathMonitorType: Sendable { + #if os(iOS) + var telephonyNetworkInfo: TelephonyInfoType { get } + #endif + var path: PathType { get } + + func onPathUpdate(_ callback: @escaping (PathType) -> Void) + func start(queue: DispatchQueue) + func cancel() +} + +extension PathMonitorType { + func connectionStatus(for path: PathType) -> ConnectionStatus { + switch path.status { + case .satisfied: .connected(connectionType(for: path)) + case .unsatisfied, .requiresConnection: .disconnected(path.unsatisfiedReason.disconnectedReason) + @unknown default: .disconnected(path.unsatisfiedReason.disconnectedReason) + } + } + + func connectionType(for path: PathType) -> ConnectionType { + if path.usesInterfaceType(.loopback) { return .loopback } + if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet } + if path.usesInterfaceType(.wifi) { return .wifi } + #if os(iOS) + if path.usesInterfaceType(.cellular) { return .cellular(telephonyNetworkInfo.cellularConnectionType) } + #endif + + return .unknown + } +} + +extension NWPathMonitor: PathMonitorType { + #if os(iOS) + var telephonyNetworkInfo: TelephonyInfoType { CTTelephonyNetworkInfo() } + #endif + var path: PathType { currentPath } + + func onPathUpdate(_ callback: @escaping (PathType) -> Void) { + pathUpdateHandler = { callback($0) } + } +} diff --git a/Sources/PathMonitor/PathType.swift b/Sources/PathMonitor/PathType.swift new file mode 100644 index 0000000..3bf8a05 --- /dev/null +++ b/Sources/PathMonitor/PathType.swift @@ -0,0 +1,12 @@ +import Network + +protocol PathType: Sendable { + var status: NWPath.Status { get } + var unsatisfiedReason: NWPath.UnsatisfiedReason { get } + var isExpensive: Bool { get } + var isConstrained: Bool { get } + + func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool +} + +extension NWPath: PathType {} diff --git a/Sources/PathMonitor/TelephonyInfoType.swift b/Sources/PathMonitor/TelephonyInfoType.swift new file mode 100644 index 0000000..0e6b2fe --- /dev/null +++ b/Sources/PathMonitor/TelephonyInfoType.swift @@ -0,0 +1,70 @@ +#if os(iOS) +import CoreTelephony + +protocol TelephonyInfoType: Sendable { + var serviceCurrentRadioAccessTechnology: [String: String]? { get } + var currentRadioAccessTechnology: String? { get } +} + +extension CTTelephonyNetworkInfo: TelephonyInfoType {} + +extension TelephonyInfoType { + var currentRadioAccessTechnologies: Set { + guard let technology = serviceCurrentRadioAccessTechnology else { return [] } + return Set(technology.values) + } + + var cellularConnectionType: ConnectionType.Cellular { + let currentTechnologies = currentRadioAccessTechnologies + let connectionType = technologiesMapping + .sorted { $0.key.priority > $1.key.priority } + .first { !$0.value.isDisjoint(with: currentTechnologies) }? + .key + + return connectionType ?? .other + } + + private var technologiesMapping: [ConnectionType.Cellular: Set] { + [ + .cellular5G: cellular5GTechnologies, + .cellular4G: [ + CTRadioAccessTechnologyLTE + ], + .cellular3G: [ + CTRadioAccessTechnologyWCDMA, + CTRadioAccessTechnologyHSDPA, + CTRadioAccessTechnologyHSUPA, + CTRadioAccessTechnologyCDMAEVDORev0, + CTRadioAccessTechnologyCDMAEVDORevA, + CTRadioAccessTechnologyCDMAEVDORevB, + CTRadioAccessTechnologyeHRPD + ], + .cellular2G: [ + CTRadioAccessTechnologyGPRS, + CTRadioAccessTechnologyEdge, + CTRadioAccessTechnologyCDMA1x + ] + ] + } + + private var cellular5GTechnologies: Set { + guard #available(iOS 14.1, *) else { return [] } + return [ + CTRadioAccessTechnologyNRNSA, + CTRadioAccessTechnologyNR + ] + } +} + +extension ConnectionType.Cellular { + fileprivate var priority: Int { + switch self { + case .cellular5G: 5 + case .cellular4G: 4 + case .cellular3G: 3 + case .cellular2G: 2 + case .other: 1 + } + } +} +#endif diff --git a/Sources/Reachability/Reachability.swift b/Sources/Reachability/Reachability.swift new file mode 100644 index 0000000..cadb48f --- /dev/null +++ b/Sources/Reachability/Reachability.swift @@ -0,0 +1,58 @@ +import Network +import Combine + +@MainActor +public final class Reachability: Sendable, ObservableObject { + public static let shared = Reachability() + + private let monitor: PathMonitorType + + @Published public private(set) var status: ConnectionStatus + @Published public private(set) var isExpensive: Bool + @Published public private(set) var isConstrained: Bool + + init(monitor: PathMonitorType = NWPathMonitor()) { + self.monitor = monitor + self.status = monitor.connectionStatus(for: monitor.path) + self.isExpensive = monitor.path.isExpensive + self.isConstrained = monitor.path.isConstrained + observeNetworkPathChanges() + } + + deinit { + monitor.cancel() + } + + public func changes() -> AsyncStream { + $status + .removeDuplicates() + .values + .eraseToStream() + } + + public func expensiveChanges() -> AsyncStream { + $isExpensive + .removeDuplicates() + .values + .eraseToStream() + } + + public func constrainedChanges() -> AsyncStream { + $isConstrained + .removeDuplicates() + .values + .eraseToStream() + } +} + +extension Reachability { + private func observeNetworkPathChanges() { + monitor.start(queue: DispatchQueue.global(qos: .utility)) + monitor.onPathUpdate { [weak self] path in + guard let self else { return } + status = monitor.connectionStatus(for: path) + isExpensive = path.isExpensive + isConstrained = path.isConstrained + } + } +} diff --git a/Sources/Resources/PrivacyInfo.xcprivacy b/Sources/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..74f8af8 --- /dev/null +++ b/Sources/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,8 @@ + + + + + NSPrivacyAccessedAPITypes + + + diff --git a/SwiftReachability.xcworkspace/contents.xcworkspacedata b/SwiftReachability.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..9ca22f2 --- /dev/null +++ b/SwiftReachability.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/SwiftReachability.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SwiftReachability.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/SwiftReachability.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/SwiftReachability.xcworkspace/xcshareddata/xcschemes/SwiftReachability.xcscheme b/SwiftReachability.xcworkspace/xcshareddata/xcschemes/SwiftReachability.xcscheme new file mode 100644 index 0000000..9494a19 --- /dev/null +++ b/SwiftReachability.xcworkspace/xcshareddata/xcschemes/SwiftReachability.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/ConnectionStatusTests.swift b/Tests/ConnectionStatusTests.swift new file mode 100644 index 0000000..33660f7 --- /dev/null +++ b/Tests/ConnectionStatusTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import SwiftReachability + +final class ConnectionStatusTests: XCTestCase { + func test_isConnected_whenIsConnected_shouldReturnTrue() { + let status = ConnectionStatus.connected(.wifi) + XCTAssert(status.isConnected) + } + + func test_isConnected_whenIsDisconnected_shouldReturnFalse() { + let status = ConnectionStatus.disconnected(.wifiDenied) + XCTAssertFalse(status.isConnected) + } + + func test_connectionType_whenIsConnected_shouldReturnType() { + let status = ConnectionStatus.connected(.wifi) + XCTAssertEqual(status.connectionType, .wifi) + } + + func test_connectionType_whenIsDisconnected_shouldReturnNil() { + let status = ConnectionStatus.disconnected(.localNetworkDenied) + XCTAssertNil(status.connectionType) + } + + func test_isDisconnected_whenIsDisconnected_shouldReturnTrue() { + let status = ConnectionStatus.disconnected(.cellularDenied) + XCTAssert(status.isDisconnected) + } + + func test_isDisconnected_whenIsConnected_shouldReturnFalse() { + let status = ConnectionStatus.connected(.wiredEthernet) + XCTAssertFalse(status.isDisconnected) + } + + func test_disconnectedReason_whenIsDisconnected_shouldReturnReason() { + let status = ConnectionStatus.disconnected(.cellularDenied) + XCTAssertEqual(status.disconnectedReason, .cellularDenied) + } + + func test_disconnectedReason_whenIsConnected_shouldReturnNil() { + let status = ConnectionStatus.connected(.loopback) + XCTAssertNil(status.disconnectedReason) + } +} diff --git a/Tests/ConnectionTypeTests.swift b/Tests/ConnectionTypeTests.swift new file mode 100644 index 0000000..d81be70 --- /dev/null +++ b/Tests/ConnectionTypeTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import SwiftReachability + +final class ConnectionTypeTests: XCTestCase { + func test_isWifi_shouldReturnCorrectValue() { + XCTAssert(ConnectionType.wifi.isWifi) + #if os(iOS) + XCTAssertFalse(ConnectionType.cellular(.cellular5G).isWifi) + XCTAssertFalse(ConnectionType.cellular(.cellular4G).isWifi) + XCTAssertFalse(ConnectionType.cellular(.cellular3G).isWifi) + XCTAssertFalse(ConnectionType.cellular(.cellular2G).isWifi) + XCTAssertFalse(ConnectionType.cellular(.other).isWifi) + #endif + XCTAssertFalse(ConnectionType.wiredEthernet.isWifi) + XCTAssertFalse(ConnectionType.loopback.isWifi) + XCTAssertFalse(ConnectionType.unknown.isWifi) + } + + func test_isCellular_shouldReturnCorrectValue() throws { + #if os(iOS) + XCTAssertFalse(ConnectionType.wiredEthernet.isCellular) + XCTAssertFalse(ConnectionType.wifi.isCellular) + XCTAssertFalse(ConnectionType.loopback.isCellular) + XCTAssertFalse(ConnectionType.unknown.isCellular) + XCTAssert(ConnectionType.cellular(.cellular5G).isCellular) + XCTAssert(ConnectionType.cellular(.cellular4G).isCellular) + XCTAssert(ConnectionType.cellular(.cellular3G).isCellular) + XCTAssert(ConnectionType.cellular(.cellular2G).isCellular) + XCTAssert(ConnectionType.cellular(.other).isCellular) + #else + throw XCTSkip("This test is only supported on iOS") + #endif + } + + func test_isWiredEthernet_shouldReturnCorrectValue() { + XCTAssert(ConnectionType.wiredEthernet.isWiredEthernet) + XCTAssertFalse(ConnectionType.wifi.isWiredEthernet) + XCTAssertFalse(ConnectionType.loopback.isWiredEthernet) + XCTAssertFalse(ConnectionType.unknown.isWiredEthernet) + #if os(iOS) + XCTAssertFalse(ConnectionType.cellular(.cellular5G).isWiredEthernet) + XCTAssertFalse(ConnectionType.cellular(.cellular4G).isWiredEthernet) + XCTAssertFalse(ConnectionType.cellular(.cellular3G).isWiredEthernet) + XCTAssertFalse(ConnectionType.cellular(.cellular2G).isWiredEthernet) + XCTAssertFalse(ConnectionType.cellular(.other).isWiredEthernet) + #endif + } + + func test_isLoopback_shouldReturnCorrectValue() { + XCTAssert(ConnectionType.loopback.isLoopback) + XCTAssertFalse(ConnectionType.wifi.isLoopback) + XCTAssertFalse(ConnectionType.wiredEthernet.isLoopback) + XCTAssertFalse(ConnectionType.unknown.isLoopback) + #if os(iOS) + XCTAssertFalse(ConnectionType.cellular(.cellular5G).isLoopback) + XCTAssertFalse(ConnectionType.cellular(.cellular4G).isLoopback) + XCTAssertFalse(ConnectionType.cellular(.cellular3G).isLoopback) + XCTAssertFalse(ConnectionType.cellular(.cellular2G).isLoopback) + XCTAssertFalse(ConnectionType.cellular(.other).isLoopback) + #endif + } + + func test_isUnknown_shouldReturnCorrectValue() { + XCTAssert(ConnectionType.unknown.isUnknown) + XCTAssertFalse(ConnectionType.wifi.isUnknown) + XCTAssertFalse(ConnectionType.wiredEthernet.isUnknown) + XCTAssertFalse(ConnectionType.loopback.isUnknown) + #if os(iOS) + XCTAssertFalse(ConnectionType.cellular(.cellular5G).isUnknown) + XCTAssertFalse(ConnectionType.cellular(.cellular4G).isUnknown) + XCTAssertFalse(ConnectionType.cellular(.cellular3G).isUnknown) + XCTAssertFalse(ConnectionType.cellular(.cellular2G).isUnknown) + XCTAssertFalse(ConnectionType.cellular(.other).isUnknown) + #endif + } +} diff --git a/Tests/Helpers/FuncCheck.swift b/Tests/Helpers/FuncCheck.swift new file mode 100644 index 0000000..2f8dde3 --- /dev/null +++ b/Tests/Helpers/FuncCheck.swift @@ -0,0 +1,20 @@ +final class FuncCheck: @unchecked Sendable { + var argument: Argument? + var wasCalled: Bool { argument != nil } + + func call(_ argument: Argument) { + self.argument = argument + } +} + +extension FuncCheck where Argument == Void { + func call() { + call(()) + } +} + +extension FuncCheck where Argument: Equatable { + func wasCalled(with argument: Argument) -> Bool { + wasCalled && self.argument == argument + } +} diff --git a/Tests/Mocks/MockPath.swift b/Tests/Mocks/MockPath.swift new file mode 100644 index 0000000..9027ade --- /dev/null +++ b/Tests/Mocks/MockPath.swift @@ -0,0 +1,30 @@ +import Network +@testable import SwiftReachability + +final class MockPath: PathType { + let status: NWPath.Status + let isExpensive: Bool + let isConstrained: Bool + let availableInterfaceTypes: [NWInterface.InterfaceType] + let unsatisfiedReason: NWPath.UnsatisfiedReason + + init( + status: NWPath.Status = .satisfied, + isExpensive: Bool = false, + isConstrained: Bool = false, + unsatisfiedReason: NWPath.UnsatisfiedReason = .notAvailable, + availableInterfaceTypes: NWInterface.InterfaceType... + ) { + self.status = status + self.isExpensive = isExpensive + self.isConstrained = isConstrained + self.unsatisfiedReason = unsatisfiedReason + self.availableInterfaceTypes = availableInterfaceTypes + } + + let usesInterfaceTypeCheck = FuncCheck() + func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { + usesInterfaceTypeCheck.call(type) + return availableInterfaceTypes.contains(type) + } +} diff --git a/Tests/Mocks/MockPathMonitor.swift b/Tests/Mocks/MockPathMonitor.swift new file mode 100644 index 0000000..b9a39d6 --- /dev/null +++ b/Tests/Mocks/MockPathMonitor.swift @@ -0,0 +1,38 @@ +import Network +@testable import SwiftReachability + +final class MockPathMonitor: PathMonitorType { + #if os(iOS) + let telephonyNetworkInfo: TelephonyInfoType + #endif + let path: PathType + + #if os(iOS) + init( + telephonyNetworkInfo: TelephonyInfoType = MockTelephonyInfo(), + path: PathType = MockPath() + ) { + self.telephonyNetworkInfo = telephonyNetworkInfo + self.path = path + } + #else + init(path: PathType = MockPath()) { + self.path = path + } + #endif + + let onPathUpdateCheck = FuncCheck<(PathType) -> Void>() + func onPathUpdate(_ callback: @escaping (PathType) -> Void) { + onPathUpdateCheck.call(callback) + } + + let startCheck = FuncCheck() + func start(queue: DispatchQueue) { + startCheck.call(queue) + } + + let cancelCheck = FuncCheck() + func cancel() { + cancelCheck.call() + } +} diff --git a/Tests/Mocks/MockTelephonyInfo.swift b/Tests/Mocks/MockTelephonyInfo.swift new file mode 100644 index 0000000..3f238bb --- /dev/null +++ b/Tests/Mocks/MockTelephonyInfo.swift @@ -0,0 +1,48 @@ +#if os(iOS) +import Network +import CoreTelephony +@testable import SwiftReachability + +final class MockTelephonyInfo: TelephonyInfoType { + let serviceCurrentRadioAccessTechnology: [String: String]? + let currentRadioAccessTechnology: String? + + init(technologies: [String: String]?, currentTechnology: String? = nil) { + serviceCurrentRadioAccessTechnology = technologies + currentRadioAccessTechnology = currentTechnology + } + + init(type: ConnectionType.Cellular? = nil) { + switch type { + case .cellular2G: + serviceCurrentRadioAccessTechnology = [ + "2G": CTRadioAccessTechnologyGPRS + ] + currentRadioAccessTechnology = CTRadioAccessTechnologyGPRS + case .cellular3G: + serviceCurrentRadioAccessTechnology = [ + "3G": CTRadioAccessTechnologyeHRPD + ] + currentRadioAccessTechnology = CTRadioAccessTechnologyeHRPD + case .cellular4G: + serviceCurrentRadioAccessTechnology = [ + "4G": CTRadioAccessTechnologyLTE + ] + currentRadioAccessTechnology = CTRadioAccessTechnologyLTE + case .cellular5G: + if #available(iOS 14.1, *) { + serviceCurrentRadioAccessTechnology = [ + "5G": CTRadioAccessTechnologyNRNSA + ] + currentRadioAccessTechnology = CTRadioAccessTechnologyNRNSA + } else { + serviceCurrentRadioAccessTechnology = nil + currentRadioAccessTechnology = nil + } + case .other, .none: + serviceCurrentRadioAccessTechnology = nil + currentRadioAccessTechnology = nil + } + } +} +#endif diff --git a/Tests/PathMonitorTests.swift b/Tests/PathMonitorTests.swift new file mode 100644 index 0000000..159a0b7 --- /dev/null +++ b/Tests/PathMonitorTests.swift @@ -0,0 +1,67 @@ +import Network +import XCTest +@testable import SwiftReachability + +final class PathMonitorTests: XCTestCase { + func test_connectionStatus_whenPathIsNotSatisfied_shouldReturnDisconnected() { + let path = MockPath(status: .unsatisfied, unsatisfiedReason: .cellularDenied) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .disconnected(.cellularDenied)) + } + + func test_connectionStatus_whenPathIsSatisfied_whenThereIsWiredInterface_shouldReturnCorrectConnection() { + let monitor = MockPathMonitor(path: MockPath()) + let path = MockPath(status: .satisfied, availableInterfaceTypes: .wiredEthernet) + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .connected(.wiredEthernet)) + } + + func test_connectionStatus_whenPathIsSatisfied_whenThereIsLoopbackInterface_shouldReturnCorrectConnection() { + let monitor = MockPathMonitor(path: MockPath()) + let path = MockPath(status: .satisfied, availableInterfaceTypes: .loopback) + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .connected(.loopback)) + } + + func test_connectionStatus_whenPathIsSatisfied_whenThereIsWiFiInterface_shouldReturnCorrectConnection() { + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: MockPath()) + #else + let monitor = MockPathMonitor(path: MockPath()) + #endif + let path = MockPath(status: .satisfied, availableInterfaceTypes: .wifi) + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .connected(.wifi)) + } + + func test_connectionStatus_whenPathIsSatisfied_whenThereIsCellularInterface_shouldReturnCorrectConnection() throws { + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo(type: .cellular4G) + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: MockPath()) + let path = MockPath(status: .satisfied, availableInterfaceTypes: .cellular) + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .connected(.cellular(.cellular4G))) + #else + throw XCTSkip("This test is only available on iOS") + #endif + } + + func test_connectionStatus_whenPathIsSatisfied_whenThereIsUnsupportedInterface_shouldReturnCorrectConnection() { + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: MockPath()) + #else + let monitor = MockPathMonitor(path: MockPath()) + #endif + let path = MockPath(status: .satisfied, availableInterfaceTypes: .other) + let connectionStatus = monitor.connectionStatus(for: path) + XCTAssertEqual(connectionStatus, .connected(.unknown)) + } +} diff --git a/Tests/ReachabilityTests.swift b/Tests/ReachabilityTests.swift new file mode 100644 index 0000000..1f907a8 --- /dev/null +++ b/Tests/ReachabilityTests.swift @@ -0,0 +1,167 @@ +import XCTest +#if os(iOS) +import CoreTelephony +#endif +@testable import SwiftReachability + +final class ReachabilityTests: XCTestCase { + @MainActor + func test_isExpensive_shouldReturnCorrectValue() { + let path = MockPath(status: .satisfied, isExpensive: true) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let isExpensive = networkReachability.isExpensive + XCTAssert(isExpensive) + } + + @MainActor + func test_isConstrained_shouldReturnCorrectValue() { + let path = MockPath(status: .satisfied, isConstrained: true) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let isConstrained = networkReachability.isConstrained + XCTAssert(isConstrained) + } + + @MainActor + func test_status_shouldReturnConnectionStatus() { + let path = MockPath(status: .satisfied, availableInterfaceTypes: .wifi) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let connectionStatus = networkReachability.status + XCTAssertEqual(connectionStatus, .connected(.wifi)) + } + + @MainActor + func test_changes_whenConnectionStatusChanges_shouldNotify() async { + let path = MockPath(status: .satisfied, availableInterfaceTypes: .wifi) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo(type: .cellular4G) + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let expectation = XCTestExpectation(description: "connection status changed") + Task.detached { + for await status in await networkReachability.changes().dropFirst() { + #if os(iOS) + XCTAssertEqual(status, .connected(.cellular(.cellular4G))) + #else + XCTAssertEqual(status, .connected(.wiredEthernet)) + #endif + expectation.fulfill() + } + } + Task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + #if os(iOS) + let newPath = MockPath(status: .satisfied, availableInterfaceTypes: .cellular) + #else + let newPath = MockPath(status: .satisfied, availableInterfaceTypes: .wiredEthernet) + #endif + monitor.onPathUpdateCheck.argument?(newPath) + } + await fulfillment(of: [expectation]) + } + + @MainActor + func test_changes_whenConnectionStatusDidNotChange_shouldNotNotify() async { + let path = MockPath(status: .satisfied, availableInterfaceTypes: .wifi) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo(type: .cellular4G) + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let expectation = XCTestExpectation(description: "connection status changed") + Task.detached { + for await status in await networkReachability.changes().dropFirst() { + #if os(iOS) + XCTAssertEqual(status, .connected(.cellular(.cellular4G))) + #else + XCTAssertEqual(status, .connected(.wiredEthernet)) + #endif + expectation.fulfill() + } + } + Task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let newUnchangedPath = MockPath(status: .satisfied, availableInterfaceTypes: .wifi) + monitor.onPathUpdateCheck.argument?(newUnchangedPath) + #if os(iOS) + let newChangedPath = MockPath(status: .satisfied, availableInterfaceTypes: .cellular) + #else + let newChangedPath = MockPath(status: .satisfied, availableInterfaceTypes: .wiredEthernet) + #endif + monitor.onPathUpdateCheck.argument?(newChangedPath) + } + await fulfillment(of: [expectation]) + } + + @MainActor + func test_expensiveChanges_whenCostChanges_shouldNotify() async { + let path = MockPath(status: .satisfied, isExpensive: true) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let expectation = XCTestExpectation(description: "cost changed") + Task.detached { + for await isExpensive in await networkReachability.expensiveChanges().dropFirst() { + XCTAssertFalse(isExpensive) + expectation.fulfill() + } + } + Task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let newPath = MockPath(status: .satisfied, isExpensive: false) + monitor.onPathUpdateCheck.argument?(newPath) + } + await fulfillment(of: [expectation]) + } + + @MainActor + func test_constrainedChanges_whenRestrictionsChanges_shouldNotify() async { + let path = MockPath(status: .satisfied, isConstrained: true) + #if os(iOS) + let mockTelephonyInfo = MockTelephonyInfo() + let monitor = MockPathMonitor(telephonyNetworkInfo: mockTelephonyInfo, path: path) + #else + let monitor = MockPathMonitor(path: path) + #endif + let networkReachability = Reachability(monitor: monitor) + let expectation = XCTestExpectation(description: "restriction changed") + Task.detached { + for await isConstrained in await networkReachability.constrainedChanges().dropFirst() { + XCTAssertFalse(isConstrained) + expectation.fulfill() + } + } + Task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let newPath = MockPath(status: .satisfied, isConstrained: false) + monitor.onPathUpdateCheck.argument?(newPath) + } + await fulfillment(of: [expectation]) + } +} diff --git a/Tests/TelephonyInfoTests.swift b/Tests/TelephonyInfoTests.swift new file mode 100644 index 0000000..b3100a2 --- /dev/null +++ b/Tests/TelephonyInfoTests.swift @@ -0,0 +1,70 @@ +#if os(iOS) +import XCTest +import CoreTelephony +@testable import SwiftReachability + +final class TelephonyInfoTests: XCTestCase { + func test_currentRadioAccessTechnologies_whenServiceTechnologyAreMissing_shouldReturnEmptySet() { + let info = MockTelephonyInfo() + let technologies = info.currentRadioAccessTechnologies + XCTAssert(technologies.isEmpty) + } + + func test_currentRadioAccessTechnologies_whenServiceTechnologyArePresent_shouldReturnCorrectValues() { + let info = MockTelephonyInfo(technologies: [ + "4G": CTRadioAccessTechnologyLTE, + "3G": CTRadioAccessTechnologyeHRPD + ]) + let technologies = info.currentRadioAccessTechnologies + XCTAssertEqual(technologies, [ + CTRadioAccessTechnologyLTE, + CTRadioAccessTechnologyeHRPD + ]) + } + + @available(iOS 14.1, *) + func test_cellularConnectionType_when5GIsSupported_shouldReturn5G() { + let info = MockTelephonyInfo(technologies: [ + "5G": CTRadioAccessTechnologyNR, + "4G": CTRadioAccessTechnologyLTE, + "3G": CTRadioAccessTechnologyWCDMA, + "2G": CTRadioAccessTechnologyGPRS + ]) + let cellularConnectionType = info.cellularConnectionType + XCTAssertEqual(cellularConnectionType, .cellular5G) + } + + func test_cellularConnectionType_when4GIsSupported_shouldReturn4G() { + let info = MockTelephonyInfo(technologies: [ + "4G": CTRadioAccessTechnologyLTE, + "3G": CTRadioAccessTechnologyWCDMA, + "2G": CTRadioAccessTechnologyGPRS + ]) + let cellularConnectionType = info.cellularConnectionType + XCTAssertEqual(cellularConnectionType, .cellular4G) + } + + func test_cellularConnectionType_when3GIsSupported_shouldReturn3G() { + let info = MockTelephonyInfo(technologies: [ + "3G": CTRadioAccessTechnologyWCDMA, + "2G": CTRadioAccessTechnologyGPRS + ]) + let cellularConnectionType = info.cellularConnectionType + XCTAssertEqual(cellularConnectionType, .cellular3G) + } + + func test_cellularConnectionType_when2GIsSupported_shouldReturn2G() { + let info = MockTelephonyInfo(technologies: [ + "2G": CTRadioAccessTechnologyGPRS + ]) + let cellularConnectionType = info.cellularConnectionType + XCTAssertEqual(cellularConnectionType, .cellular2G) + } + + func test_cellularConnectionType_whenNoneIsSupported_shouldReturnOther() { + let info = MockTelephonyInfo(technologies: [:]) + let cellularConnectionType = info.cellularConnectionType + XCTAssertEqual(cellularConnectionType, .other) + } +} +#endif