diff --git a/.swiftlint.yml b/.swiftlint.yml index 793ccc0..ac04d63 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,8 +10,6 @@ only_rules: # All Images that provide context should have an accessibility label. Purely decorative images can be hidden from accessibility. - accessibility_label_for_image - # Attributes should be on their own lines in functions and types, but on the same line as variables and imports. - - attributes # Prefer using Array(seq) over seq.map { $0 } to convert a sequence into an Array. - array_init # Prefer the new block based KVO API with keypaths when using Swift 3.2 or later. diff --git a/Package.swift b/Package.swift index 353eb64..fd18de8 100644 --- a/Package.swift +++ b/Package.swift @@ -33,6 +33,7 @@ let package = Package( .testTarget( name: "SpeziHealthKitTests", dependencies: [ + .product(name: "XCTSpezi", package: "Spezi"), .target(name: "SpeziHealthKit") ] ) diff --git a/Sources/SpeziHealthKit/HealthKit.swift b/Sources/SpeziHealthKit/HealthKit.swift index 4d2453c..b3720a2 100644 --- a/Sources/SpeziHealthKit/HealthKit.swift +++ b/Sources/SpeziHealthKit/HealthKit.swift @@ -64,6 +64,23 @@ public final class HealthKit: Module { .flatMap { $0.dataSources(healthStore: healthStore, standard: standard, adapter: adapter) } }() + private var healthKitSampleTypes: Set { + healthKitDataSourceDescriptions.reduce(into: Set()) { + $0 = $0.union($1.sampleTypes) + } + } + + private var healthKitSampleTypesIdentifiers: Set { + Set(healthKitSampleTypes.map(\.identifier)) + } + + /// Indicates whether the necessary authorizations to collect all HealthKit data defined by the ``HealthKitDataSourceDescription``s are already granted. + public var authorized: Bool { + let alreadyRequestedSampleTypes = Set(UserDefaults.standard.stringArray(forKey: UserDefaults.Keys.healthKitRequestedSampleTypes) ?? []) + + return healthKitSampleTypesIdentifiers.isSubset(of: alreadyRequestedSampleTypes) + } + /// Creates a new instance of the ``HealthKit`` module. /// - Parameters: @@ -99,24 +116,22 @@ public final class HealthKit: Module { /// /// Call this function when you want to start HealthKit data collection. public func askForAuthorization() async throws { - var sampleTypes: Set = [] - - for healthKitDataSourceDescription in healthKitDataSourceDescriptions { - sampleTypes = sampleTypes.union(healthKitDataSourceDescription.sampleTypes) - } - - let requestedSampleTypes = Set(UserDefaults.standard.stringArray(forKey: UserDefaults.Keys.healthKitRequestedSampleTypes) ?? []) - guard !Set(sampleTypes.map { $0.identifier }).isSubset(of: requestedSampleTypes) else { + guard !authorized else { return } - try await healthStore.requestAuthorization(toShare: [], read: sampleTypes) + try await healthStore.requestAuthorization(toShare: [], read: healthKitSampleTypes) - UserDefaults.standard.set(sampleTypes.map { $0.identifier }, forKey: UserDefaults.Keys.healthKitRequestedSampleTypes) + UserDefaults.standard.set(Array(healthKitSampleTypesIdentifiers), forKey: UserDefaults.Keys.healthKitRequestedSampleTypes) for healthKitComponent in healthKitComponents { healthKitComponent.askedForAuthorization() } + + // Triggers an update of the UI in case the HealthKit authorizations are changed + Task { @MainActor in + self.objectWillChange.send() + } } diff --git a/Tests/SpeziHealthKitTests/Shared/MockAdapterActor.swift b/Tests/SpeziHealthKitTests/Shared/MockAdapterActor.swift new file mode 100644 index 0000000..671e7bf --- /dev/null +++ b/Tests/SpeziHealthKitTests/Shared/MockAdapterActor.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import Spezi +import SpeziHealthKit +import XCTSpezi + +actor MockAdapterActor: Adapter { + typealias InputElement = HKSample + typealias InputRemovalContext = HKSampleRemovalContext + typealias OutputElement = TestAppStandard.BaseType + typealias OutputRemovalContext = TestAppStandard.RemovalContext + + + func transform( + _ asyncSequence: some TypedAsyncSequence> + ) async -> any TypedAsyncSequence> { + asyncSequence.map { element in + element.map( + element: { OutputElement(id: String(describing: $0.id)) }, + removalContext: { OutputRemovalContext(id: $0.id.uuidString) } + ) + } + } +} diff --git a/Tests/SpeziHealthKitTests/SpeziContactTests.swift b/Tests/SpeziHealthKitTests/SpeziContactTests.swift deleted file mode 100644 index 72d40aa..0000000 --- a/Tests/SpeziHealthKitTests/SpeziContactTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -@testable import SpeziHealthKit -import XCTest - - -final class SpeziHealthKitTests: XCTestCase { - func testSpeziHealthKitTests() throws { - XCTAssert(true) - } -} diff --git a/Tests/SpeziHealthKitTests/SpeziHealthKitTests.swift b/Tests/SpeziHealthKitTests/SpeziHealthKitTests.swift new file mode 100644 index 0000000..e27c4a5 --- /dev/null +++ b/Tests/SpeziHealthKitTests/SpeziHealthKitTests.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@testable import SpeziHealthKit +import XCTest +import XCTSpezi + +final class SpeziHealthKitTests: XCTestCase { + static let collectedSamples: Set = [ + HKQuantityType(.stepCount), + HKQuantityType(.distanceWalkingRunning) + ] + + let healthKitComponent: HealthKit = HealthKit { + CollectSamples( + collectedSamples, + deliverySetting: .anchorQuery(.afterAuthorizationAndApplicationWillLaunch) + ) + } adapter: { + MockAdapterActor() + } + + override func tearDown() { + // Clean up UserDefaults + UserDefaults.standard.removeObject(forKey: UserDefaults.Keys.healthKitRequestedSampleTypes) + } + + /// No authorizations for HealthKit data are given in the ``UserDefaults`` + func testSpeziHealthKitCollectionNotAuthorized1() { + XCTAssert(!healthKitComponent.authorized) + } + + /// Not enough authorizations for HealthKit data given in the ``UserDefaults`` + func testSpeziHealthKitCollectionNotAuthorized2() { + // Set up UserDefaults + UserDefaults.standard.set( + Array(Self.collectedSamples.map { $0.identifier }.dropLast()), // Drop one of the required authorizations + forKey: UserDefaults.Keys.healthKitRequestedSampleTypes + ) + + XCTAssert(!healthKitComponent.authorized) + } + + /// Authorization for HealthKit data are given in the ``UserDefaults`` + func testSpeziHealthKitCollectionAlreadyAuthorized() { + // Set up UserDefaults + UserDefaults.standard.set( + Self.collectedSamples.map { $0.identifier }, + forKey: UserDefaults.Keys.healthKitRequestedSampleTypes + ) + + XCTAssert(healthKitComponent.authorized) + } +} diff --git a/Tests/UITests/TestApp/HealthKitTestsView.swift b/Tests/UITests/TestApp/HealthKitTestsView.swift index 5fda23a..c4e9c0b 100644 --- a/Tests/UITests/TestApp/HealthKitTestsView.swift +++ b/Tests/UITests/TestApp/HealthKitTestsView.swift @@ -23,6 +23,8 @@ struct HealthKitTestsView: View { Button("Ask for authorization") { askForAuthorization() } + .disabled(healthKitComponent.authorized) + Button("Trigger data source collection") { triggerDataSourceCollection() } diff --git a/Tests/UITests/TestAppUITests/SpeziHealthKitTests.swift b/Tests/UITests/TestAppUITests/SpeziHealthKitTests.swift index e08fbd9..de12174 100644 --- a/Tests/UITests/TestAppUITests/SpeziHealthKitTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziHealthKitTests.swift @@ -148,6 +148,29 @@ final class HealthKitTests: XCTestCase { ] ) } + + func testRepeatedHealthKitAuthorization() throws { + let app = XCUIApplication() + app.deleteAndLaunch(withSpringboardAppName: "TestApp") + + app.activate() + XCTAssert(app.buttons["Ask for authorization"].waitForExistence(timeout: 2)) + XCTAssert(app.buttons["Ask for authorization"].isEnabled) + app.buttons["Ask for authorization"].tap() + + try app.handleHealthKitAuthorization() + + // Wait for button to become disabled + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !app.buttons["Ask for authorization"].isEnabled + }, + object: .none + ) + wait(for: [expectation], timeout: 2) + + XCTAssert(!app.buttons["Ask for authorization"].isEnabled) + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index c1cc8b1..1cd884c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -16,8 +16,8 @@ 2F85827829E776D10021D637 /* XCTSpezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F85827729E776D10021D637 /* XCTSpezi */; }; 2F85827A29E777980021D637 /* TestAppHealthKitAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F85827929E777980021D637 /* TestAppHealthKitAdapter.swift */; }; 2F85827F29E7782C0021D637 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F85827E29E7782C0021D637 /* XCTestExtensions */; }; - 2F85828229E7784C0021D637 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2F85828129E7784C0021D637 /* XCTHealthKit */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; + 97B029102A5710C800946EF8 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 97B0290F2A5710C800946EF8 /* XCTHealthKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,7 +60,7 @@ buildActionMask = 2147483647; files = ( 2F85827F29E7782C0021D637 /* XCTestExtensions in Frameworks */, - 2F85828229E7784C0021D637 /* XCTHealthKit in Frameworks */, + 97B029102A5710C800946EF8 /* XCTHealthKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -157,7 +157,7 @@ name = TestAppUITests; packageProductDependencies = ( 2F85827E29E7782C0021D637 /* XCTestExtensions */, - 2F85828129E7784C0021D637 /* XCTHealthKit */, + 97B0290F2A5710C800946EF8 /* XCTHealthKit */, ); productName = ExampleUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; @@ -194,7 +194,7 @@ packageReferences = ( 2F85827429E776D10021D637 /* XCRemoteSwiftPackageReference "Spezi" */, 2F85827B29E778110021D637 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, - 2F85828029E7784C0021D637 /* XCRemoteSwiftPackageReference "XCTHealthKit" */, + 97B0290E2A5710C800946EF8 /* XCRemoteSwiftPackageReference "XCTHealthKit" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -634,15 +634,15 @@ repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.1; + minimumVersion = 0.4.6; }; }; - 2F85828029E7784C0021D637 /* XCRemoteSwiftPackageReference "XCTHealthKit" */ = { + 97B0290E2A5710C800946EF8 /* XCRemoteSwiftPackageReference "XCTHealthKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTHealthKit"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.3.3; + kind = upToNextMajorVersion; + minimumVersion = 0.3.5; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -667,9 +667,9 @@ package = 2F85827B29E778110021D637 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; - 2F85828129E7784C0021D637 /* XCTHealthKit */ = { + 97B0290F2A5710C800946EF8 /* XCTHealthKit */ = { isa = XCSwiftPackageProductDependency; - package = 2F85828029E7784C0021D637 /* XCRemoteSwiftPackageReference "XCTHealthKit" */; + package = 97B0290E2A5710C800946EF8 /* XCRemoteSwiftPackageReference "XCTHealthKit" */; productName = XCTHealthKit; }; /* End XCSwiftPackageProductDependency section */