From 1692caac93f9703f3545543511ddb62c1fd65453 Mon Sep 17 00:00:00 2001 From: Fabian Aggeler Date: Thu, 25 Jun 2020 17:24:19 +0200 Subject: [PATCH 01/10] Add links to Apple docs --- EXPOSURE_NOTIFICATION_API_USAGE.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/EXPOSURE_NOTIFICATION_API_USAGE.md b/EXPOSURE_NOTIFICATION_API_USAGE.md index f7e6db0b..409279c4 100644 --- a/EXPOSURE_NOTIFICATION_API_USAGE.md +++ b/EXPOSURE_NOTIFICATION_API_USAGE.md @@ -3,27 +3,27 @@ This document outlines the interaction of the SDK with the [Exposure Notificatio ## Enabling Exposure Notifications -To enable Exposure Notifications for our app we need to call ENManager.setExposureNotificationEnabled(true:completionHandler:). This will trigger a system popup asking the user to either enable Exposure Notifications on this device or (if another app is active) to switch to our app as active Exposure Notifications app. After the user gave or denied consent the completionHandler will be called. +To enable Exposure Notifications for our app we need to call [ENManager.setExposureNotificationEnabled(true:completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583729-setexposurenotificationenabled). This will trigger a system popup asking the user to either enable Exposure Notifications on this device or (if another app is active) to switch to our app as active Exposure Notifications app. After the user gave or denied consent the completionHandler will be called. ## Disabling Exposure Notifications -To disable Exposure Notifications for our app we need to call ENManager.setExposureNotificationEnabled(false:completionHandler:). +To disable Exposure Notifications for our app we need to call [ENManager.setExposureNotificationEnabled(false:completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583729-setexposurenotificationenabled). ## Exporting Temporary Exposure Keys -To retrieve the Temporary Exposure Keys (TEKs) we need to call ENManager.getDiagnosisKeys(completionHandler:). This will trigger a system popup asking the user if he wants to share the TEKs of the last 14 days with the app. If the user agrees to share the keys with the app the completion handler will get called with a maximum of 14 TEKs. +To retrieve the Temporary Exposure Keys (TEKs) we need to call [ENManager.getDiagnosisKeys(completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583725-getdiagnosiskeys). This will trigger a system popup asking the user whether he wants to share the TEKs of the last 14 days with the app. If the user agrees to share the keys with the app the completion handler will get called with a maximum of 14 TEKs. -The TEK of the current day is never returned by ENManager.getDiagnosisKeys, but only the keys of the previous 13 days. After the user agreed to share the keys we can call ENManager.getDiagnosisKeys again on the following day and will then receive the TEK of the day the user agreed to share the keys as well. For this to work, the user has to open the App and give consent and we enable Exposure Notifications call ENManager.getDiagnosisKeys and disable it again afterwards. +The TEK of the current day is currently not returned by [getDiagnosisKeys(completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583725-getdiagnosiskeys), but only the keys of the previous 13 days. After the user agreed to share the keys we call [getDiagnosisKeys(completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583725-getdiagnosiskeys) again on the following day and will then receive the TEK of last day. For this to work, the user has to open the app, after which we will temporarily enable EN, then call [getDiagnosisKeys(completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3583725-getdiagnosiskeys) (which triggers a system popup once once) and disable EN again. ## Detecting Exposure -For a contact to be counted as a possible exposure it must be longer than a certain number of minutes on a certain day. The current implementation of the EN-framework does not expose this information. Our way to overcome this limitation is to pass the published keys for each day individually to the framework. +For a contact to be counted as a possible exposure it must be longer than a certain number of minutes on a certain day. The current implementation of the EN-framework does not expose this information. Our way to overcome this limitation is to pass the published keys to the framework grouped by day. -To check for exposure on a given day (we check the past 10 days) we need to call ENManager.detectExposures(configuration:diagnosisKeyURLs:completionHandler:). This method has three parameters: +To check for exposure on a given day (we check the past 10 days) we need to call [ENManager.detectExposures(configuration:diagnosisKeyURLs:completionHandler:)](https://developer.apple.com/documentation/exposurenotification/enmanager/3586331-detectexposures). This method has three parameters: #### Exposure Configuration -The exposure configuration defines the configuration for the Apple scoring of exposures. In our case we ignore most of the scoring methods and only provide the thresholds for the duration at attenuation buckets. The thresholds for the attenuation buckets are loaded from our [config server](https://github.com/DP-3T/dp3t-config-backend-ch/blob/master/dpppt-config-backend/src/main/java/org/dpppt/switzerland/backend/sdk/config/ws/model/GAENSDKConfig.java). This allows us to group the duration of a contact with another device into three buckets regarding the measured attenuation values that we then use to detect if the contact was long enough and close ennough. +The [ENExposureConfiguration](https://developer.apple.com/documentation/exposurenotification/enexposureconfiguration) defines the configuration for the Apple scoring of exposures. In our case we ignore most of the scoring methods and only provide [attenuationDurationThresholds](https://developer.apple.com/documentation/exposurenotification/enexposureconfiguration/3601128-attenuationdurationthresholds), the thresholds for the duration at attenuation buckets. The thresholds for the attenuation buckets are loaded from our [config server](https://github.com/DP-3T/dp3t-config-backend-ch/blob/master/dpppt-config-backend/src/main/java/org/dpppt/switzerland/backend/sdk/config/ws/model/GAENSDKConfig.java). This allows us to group the duration of a contact with another device into three buckets regarding the measured attenuation values that we then use to detect if the contact was long and close enough. To detect an exposure the following formula is used to compute the exposure duration: ``` durationAttenuationLow * factorLow + durationAtttenuationMedium * factorMedium @@ -32,12 +32,12 @@ If this duration is at least as much as defined in the triggerThreshold a notifi #### Diagnosis key URLs -We need to unzip the file which we got from our backend and save them locally and pass the local urls to the Framework. Unlike Andorid on iOS we can't just pass the difference from last detection but we have to pass the every key of a day everytime we do a detection. +We need to unzip the file which we got from our backend, store the key file (.bin) and signature file (.sig) locally and pass the local urls to the EN API. Unlike Android, on iOS we can't just pass the difference from last detection but we have to pass the every key of a day everytime we do a detection. #### Completion Handler -The completionHandler is called with a ENExposureDetectionSummary. That allows us to check if the exposure limit for a notification was reached by checking the minutes of exposure per attenuation window. The duration per window has a maximum of 30min, longer exposures are also returned as 30min of exposure. +The completionHandler is called with a [ENExposureDetectionSummary](https://developer.apple.com/documentation/exposurenotification/enexposuredetectionsummary). That allows us to check if the exposure limit for a notification was reached by checking the minutes of exposure per attenuation bucket. The duration per bucket has a maximum of 30min, longer exposures are also returned as 30min of exposure. #### Rate limit -We are only allowed to call provideDiagnosisKeys() 20 times within 24h. Because we check for every of the past 10 days individually, this allows us to check for exposure twice per day. These checks happen after 6am and 6pm (swiss time) when the BackgroundTask is scheduled the next time or the app is opened. All 10 days are checked individually and if one fails it is retried on the next run. No checks are made between midnight UTC and 6am (swiss time) to prevent exceeding the rate limit per UTC day. \ No newline at end of file +We are only allowed to call [detectExposures()](https://developer.apple.com/documentation/exposurenotification/enmanager/3586331-detectexposures) 20 times within 24h. Because we check for every of the past 10 days individually, this allows us to check for exposure twice per day. These checks happen after 6am and 6pm (swiss time) when the BackgroundTask is scheduled the next time or the app is opened. All 10 days are checked individually and if one fails it is retried on the next run. No checks are made between midnight UTC and 6am (swiss time) to prevent exceeding the rate limit per 24h. \ No newline at end of file From f6c5579942ea20aa5934042f0046c652f4fb0315 Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 22 Jun 2020 08:58:18 +0200 Subject: [PATCH 02/10] adds initialization state --- .../DP3TSampleApp/ControlViewController.swift | 2 + Sources/DP3TSDK/DP3TSDK.swift | 50 +++++++++++-------- Sources/DP3TSDK/DP3TTracingState.swift | 6 ++- .../Tracing/ExposureNotificationTracer.swift | 4 +- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/SampleApp/DP3TSampleApp/ControlViewController.swift b/SampleApp/DP3TSampleApp/ControlViewController.swift index 28055911..d90209ca 100644 --- a/SampleApp/DP3TSampleApp/ControlViewController.swift +++ b/SampleApp/DP3TSampleApp/ControlViewController.swift @@ -484,6 +484,8 @@ extension ControlViewController: UITextFieldDelegate { private extension TrackingState { var stringValue: String { switch self { + case .initialization: + return "initialization" case .active: return "active" case let .inactive(error): diff --git a/Sources/DP3TSDK/DP3TSDK.swift b/Sources/DP3TSDK/DP3TSDK.swift index 0e562d7f..393d2871 100644 --- a/Sources/DP3TSDK/DP3TSDK.swift +++ b/Sources/DP3TSDK/DP3TSDK.swift @@ -131,7 +131,7 @@ class DP3TSDK { self.backgroundTaskManager = backgroundTaskManager self.defaults = defaults - self.state = TracingState(trackingState: .stopped, + self.state = TracingState(trackingState: .initialization, lastSync: defaults.lastSync, infectionStatus: InfectionStatus.getInfectionState(from: exposureDayStorage), backgroundRefreshState: UIApplication.shared.backgroundRefreshStatus) @@ -185,28 +185,38 @@ class DP3TSDK { } OperationQueue().addOperation(outstandingPublishOperation) - // Skip sync when tracing is not active - if self.state.trackingState != .active { - log.error("Skip sync when tracking is not active") - callback?(.skipped) - return - } + let sync = { + // Skip sync when tracing is not active + if self.state.trackingState != .active { + self.log.error("Skip sync when tracking is not active") + callback?(.skipped) + return + } - group.enter() - var storedResult: Result? - synchronizer.sync { result in - storedResult = result - group.leave() + group.enter() + var storedResult: Result? + self.synchronizer.sync { result in + storedResult = result + group.leave() + } + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + switch storedResult! { + case .success: + self.state.lastSync = Date() + callback?(.success) + case let .failure(error): + callback?(.failure(error)) + } + } } - group.notify(queue: .main) { [weak self] in - guard let self = self else { return } - switch storedResult! { - case .success: - self.state.lastSync = Date() - callback?(.success) - case let .failure(error): - callback?(.failure(error)) + + if self.state.trackingState == .initialization { + DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + sync() } + } else { + sync() } } diff --git a/Sources/DP3TSDK/DP3TTracingState.swift b/Sources/DP3TSDK/DP3TTracingState.swift index ea21da15..5ad56b4f 100644 --- a/Sources/DP3TSDK/DP3TTracingState.swift +++ b/Sources/DP3TSDK/DP3TTracingState.swift @@ -36,6 +36,8 @@ public enum InfectionStatus { /// The tracking state of the bluetooth and the other networking api public enum TrackingState: Equatable { + /// The tracker is not fully initialized + case initialization /// The tracking is active and working fine case active /// The tracking is stopped by the user @@ -45,8 +47,8 @@ public enum TrackingState: Equatable { public static func == (lhs: TrackingState, rhs: TrackingState) -> Bool { switch (lhs, rhs) { - case (.active, .active): - return true + case (.active, .active): fallthrough + case (.initialization, initialization): fallthrough case (.stopped, stopped): return true case let (.inactive(lhsError), .inactive(rhsError)): diff --git a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift index ebb9e494..ab6e2eab 100644 --- a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift +++ b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift @@ -33,7 +33,7 @@ class ExposureNotificationTracer: Tracer { init(manager: ENManager) { self.manager = manager - state = .stopped + state = .initialization logger.log("calling ENMananger.activate") manager.activate { [weak self] error in @@ -125,6 +125,8 @@ extension TrackingState { extension TrackingState: CustomDebugStringConvertible { public var debugDescription: String { switch self { + case .initialization: + return "initialization" case .active: return "active" case .stopped: From c84fe7fe8ed4dbd10ccd836fb32d0b02a84f82eb Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 22 Jun 2020 14:02:49 +0200 Subject: [PATCH 03/10] adds callback logic --- Sources/DP3TSDK/DP3TSDK.swift | 2 +- .../Tracing/ExposureNotificationTracer.swift | 28 ++++++++++++++++--- Sources/DP3TSDK/Tracing/TracerProtocols.swift | 2 ++ Tests/DP3TSDKTests/DP3TSDKTests.swift | 7 ++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Sources/DP3TSDK/DP3TSDK.swift b/Sources/DP3TSDK/DP3TSDK.swift index 393d2871..927293bb 100644 --- a/Sources/DP3TSDK/DP3TSDK.swift +++ b/Sources/DP3TSDK/DP3TSDK.swift @@ -212,7 +212,7 @@ class DP3TSDK { } if self.state.trackingState == .initialization { - DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + tracer.addInitialisationCallback { sync() } } else { diff --git a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift index ab6e2eab..da109215 100644 --- a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift +++ b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift @@ -20,6 +20,10 @@ class ExposureNotificationTracer: Tracer { var delegate: TracerDelegate? + private let queue = DispatchQueue(label: "org.dpppt.tracer") + + private var initializationCallbacks: [ () -> Void ] = [] + private let logger = Logger(ExposureNotificationTracer.self, category: "exposureNotificationTracer") private(set) var state: TrackingState { @@ -38,10 +42,15 @@ class ExposureNotificationTracer: Tracer { logger.log("calling ENMananger.activate") manager.activate { [weak self] error in guard let self = self else { return } - if let error = error { - self.logger.error("ENMananger.activate failed error: %{public}@", error.localizedDescription) - } else { - self.initializeObservers() + self.queue.async { + if let error = error { + self.logger.error("ENMananger.activate failed error: %{public}@", error.localizedDescription) + } else { + self.initializeObservers() + } + self.logger.log("notify callbacks after initialisation (count: %d)", self.initializationCallbacks.count) + self.initializationCallbacks.forEach { $0() } + self.initializationCallbacks.removeAll() } } NotificationCenter.default.addObserver(self, @@ -50,6 +59,17 @@ class ExposureNotificationTracer: Tracer { object: nil) } + func addInitialisationCallback(callback: @escaping ()-> Void ){ + queue.sync { + self.logger.trace() + guard self.state == .initialization else { + callback() + return + } + initializationCallbacks.append(callback) + } + } + deinit { manager.invalidate() NotificationCenter.default.removeObserver(self) diff --git a/Sources/DP3TSDK/Tracing/TracerProtocols.swift b/Sources/DP3TSDK/Tracing/TracerProtocols.swift index 06a397f5..9b56d294 100644 --- a/Sources/DP3TSDK/Tracing/TracerProtocols.swift +++ b/Sources/DP3TSDK/Tracing/TracerProtocols.swift @@ -20,4 +20,6 @@ protocol Tracer { var state: TrackingState { get } func setEnabled(_ enabled: Bool, completionHandler: ((Error?) -> Void)?) + + func addInitialisationCallback(callback: @escaping ()-> Void ) } diff --git a/Tests/DP3TSDKTests/DP3TSDKTests.swift b/Tests/DP3TSDKTests/DP3TSDKTests.swift index f1228ee7..820ed096 100644 --- a/Tests/DP3TSDKTests/DP3TSDKTests.swift +++ b/Tests/DP3TSDKTests/DP3TSDKTests.swift @@ -14,6 +14,7 @@ import Foundation import XCTest private class MockTracer: Tracer { + var delegate: TracerDelegate? var state: TrackingState = .active @@ -24,6 +25,10 @@ private class MockTracer: Tracer { isEnabled = enabled completionHandler?(nil) } + + func addInitialisationCallback(callback: @escaping () -> Void) { + callback() + } } private class MockKeyProvider: DiagnosisKeysProvider { @@ -79,7 +84,7 @@ class DP3TSDKTests: XCTestCase { case .failure(_): XCTFail() case let .success(state): - XCTAssert(state.trackingState == .stopped) + XCTAssert(state.trackingState == .initialization) } exp.fulfill() } From e7b9ca0381c2f12faf67d764824bf01b90d3253d Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 22 Jun 2020 14:39:46 +0200 Subject: [PATCH 04/10] adds simple unit test --- .../ExposureNotificationTracerTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift diff --git a/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift new file mode 100644 index 00000000..d1f1449e --- /dev/null +++ b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@testable import DP3TSDK +import XCTest +import ExposureNotification + +class ExposureNotificationTracerTests: XCTestCase { + func testCallingCallbacks() { + let manager = ENManager() + let tracer = ExposureNotificationTracer(manager: manager) + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + ex.fulfill() + } + wait(for: [ex], timeout: 1) + } +} From 5e58d0128a59e0ff2306b84f62031d16a1a3fc83 Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 29 Jun 2020 10:37:26 +0200 Subject: [PATCH 05/10] adds more unit tests --- .../Tracing/ExposureNotificationTracer.swift | 13 +- Tests/DP3TSDKTests/DP3TSDKTests.swift | 40 ++++-- .../ExposureNotificationMatcherTests.swift | 42 +------ .../ExposureNotificationTracerTests.swift | 114 +++++++++++++++++- Tests/DP3TSDKTests/Mocks/MockENManager.swift | 94 +++++++++++++++ 5 files changed, 252 insertions(+), 51 deletions(-) create mode 100644 Tests/DP3TSDKTests/Mocks/MockENManager.swift diff --git a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift index da109215..3bbb0f6d 100644 --- a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift +++ b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift @@ -26,6 +26,8 @@ class ExposureNotificationTracer: Tracer { private let logger = Logger(ExposureNotificationTracer.self, category: "exposureNotificationTracer") + private let managerClass: ENManager.Type + private(set) var state: TrackingState { didSet { guard oldValue != state else { return } @@ -34,8 +36,9 @@ class ExposureNotificationTracer: Tracer { } } - init(manager: ENManager) { + init(manager: ENManager, managerClass: ENManager.Type = ENManager.self) { self.manager = manager + self.managerClass = managerClass state = .initialization @@ -49,7 +52,9 @@ class ExposureNotificationTracer: Tracer { self.initializeObservers() } self.logger.log("notify callbacks after initialisation (count: %d)", self.initializationCallbacks.count) - self.initializationCallbacks.forEach { $0() } + self.initializationCallbacks.forEach { + DispatchQueue.main.async(execute: $0) + } self.initializationCallbacks.removeAll() } } @@ -63,7 +68,7 @@ class ExposureNotificationTracer: Tracer { queue.sync { self.logger.trace() guard self.state == .initialization else { - callback() + DispatchQueue.main.async(execute: callback) return } initializationCallbacks.append(callback) @@ -93,7 +98,7 @@ class ExposureNotificationTracer: Tracer { func updateState() { state = .init(state: manager.exposureNotificationStatus, - authorizationStatus: ENManager.authorizationStatus, + authorizationStatus: managerClass.authorizationStatus, enabled: manager.exposureNotificationEnabled) } diff --git a/Tests/DP3TSDKTests/DP3TSDKTests.swift b/Tests/DP3TSDKTests/DP3TSDKTests.swift index 820ed096..8d7c44e9 100644 --- a/Tests/DP3TSDKTests/DP3TSDKTests.swift +++ b/Tests/DP3TSDKTests/DP3TSDKTests.swift @@ -46,21 +46,29 @@ private class MockKeyProvider: DiagnosisKeysProvider { class DP3TSDKTests: XCTestCase { fileprivate var keychain: MockKeychain! - fileprivate var tracer: MockTracer! - fileprivate var matcher: MockMatcher! + fileprivate var tracer: ExposureNotificationTracer! + fileprivate var matcher: ExposureNotificationMatcher! fileprivate var service: MockService! fileprivate var defaults: MockDefaults! fileprivate var keyProvider: MockKeyProvider! fileprivate var descriptor: ApplicationDescriptor! fileprivate var backgroundTaskManager: DP3TBackgroundTaskManager! + fileprivate var manager: MockENManager! + fileprivate var exposureDayStorage: ExposureDayStorage! fileprivate var sdk: DP3TSDK! override func setUp() { keychain = MockKeychain() - tracer = MockTracer() - matcher = MockMatcher() - service = MockService() + + manager = MockENManager() + + exposureDayStorage = ExposureDayStorage(keychain: keychain) + defaults = MockDefaults() + tracer = ExposureNotificationTracer(manager: manager, managerClass: MockENManager.self) + matcher = ExposureNotificationMatcher(manager: manager, exposureDayStorage: exposureDayStorage, defaults: defaults) + + service = MockService() keyProvider = MockKeyProvider() descriptor = ApplicationDescriptor(appId: "org.dpppt", bucketBaseUrl: URL(string: "http://google.com")!, reportBaseUrl: URL(string: "http://google.com")!) backgroundTaskManager = DP3TBackgroundTaskManager(handler: nil, keyProvider: keyProvider, serviceClient: service) @@ -69,7 +77,7 @@ class DP3TSDKTests: XCTestCase { tracer: tracer, matcher: matcher, diagnosisKeysProvider: keyProvider, - exposureDayStorage: ExposureDayStorage(keychain: keychain), + exposureDayStorage: exposureDayStorage, outstandingPublishesStorage: OutstandingPublishStorage(keychain: keychain), service: service, synchronizer: KnownCasesSynchronizer(matcher: matcher, service: service, defaults: defaults, descriptor: descriptor), @@ -97,7 +105,7 @@ class DP3TSDKTests: XCTestCase { exp.fulfill() } wait(for: [exp], timeout: 0.1) - XCTAssert(tracer.isEnabled) + XCTAssertEqual(tracer.state, TrackingState.active) } func testInfected(){ @@ -161,4 +169,22 @@ class DP3TSDKTests: XCTestCase { XCTAssertThrowsError(try sdk.startTracing()) } + + func testSyncDontCompleteBeforeInit(){ + let exp = expectation(description: "sync") + exp.isInverted = true + sdk.sync(runningInBackground: false) { (result) in + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) + } + + func testSyncCompleteAfterInit(){ + let exp = expectation(description: "sync") + sdk.sync(runningInBackground: false) { (result) in + exp.fulfill() + } + manager.completeActivation() + wait(for: [exp], timeout: 0.1) + } } diff --git a/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift b/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift index 065fdbc7..741a3c36 100644 --- a/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift +++ b/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift @@ -11,42 +11,10 @@ import Foundation @testable import DP3TSDK -import ExposureNotification import Foundation import XCTest import ZIPFoundation -private class MockSummary: ENExposureDetectionSummary { - override var attenuationDurations: [NSNumber] { - get { - internalAttenutationDurations - } - set { - internalAttenutationDurations = newValue - } - } - - private var internalAttenutationDurations: [NSNumber] = [0, 0, 0] -} - -private class MockManager: ENManager { - var detectExposuresWasCalled = false - - var data: [Data] = [] - - var summary = MockSummary() - - override func detectExposures(configuration _: ENExposureConfiguration, diagnosisKeyURLs: [URL], completionHandler: @escaping ENDetectExposuresHandler) -> Progress { - detectExposuresWasCalled = true - completionHandler(summary, nil) - diagnosisKeyURLs.forEach { - let diagData = try! Data(contentsOf: $0) - data.append(diagData) - } - return Progress() - } -} - final class ExposureNotificationMatcherTests: XCTestCase { var keychain = MockKeychain() @@ -55,7 +23,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testCallingOfMatcher() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage) @@ -70,7 +38,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testCallingMatcherMultithreaded() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage) @@ -85,7 +53,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatch() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) @@ -104,7 +72,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatchFirstBucketOnly() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) @@ -124,7 +92,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatchSecondBucketOnly() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) diff --git a/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift index d1f1449e..a6cab55c 100644 --- a/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift +++ b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift @@ -10,16 +10,124 @@ @testable import DP3TSDK import XCTest -import ExposureNotification class ExposureNotificationTracerTests: XCTestCase { + + var manager: MockENManager! + var tracer: ExposureNotificationTracer! + + override func setUp() { + self.manager = MockENManager() + self.tracer = ExposureNotificationTracer(manager: manager, managerClass: MockENManager.self) + } + func testCallingCallbacks() { - let manager = ENManager() - let tracer = ExposureNotificationTracer(manager: manager) let ex = expectation(description: "init") tracer.addInitialisationCallback { ex.fulfill() } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testCallingCallbacksAfterActivate() { + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + + let ex1 = expectation(description: "afterInit") + tracer.addInitialisationCallback { + ex1.fulfill() + } + wait(for: [ex1], timeout: 1) + } + + func testStatusActive(){ + manager.status = .active + MockENManager.authStatus = .authorized + manager.isEnabled = true + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.active) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusStopped(){ + manager.status = .active + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.stopped) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusInctiveBluetoothOff(){ + manager.status = .bluetoothOff + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .bluetoothTurnedOff)) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusPermission(){ + manager.status = .restricted + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .permissonError)) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusInitialisation(){ + XCTAssertEqual(tracer.state, TrackingState.initialization) + } + + func testKVOStatusUpdate(){ + manager.status = .restricted + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .permissonError)) + + self.manager.status = .active + self.manager.isEnabled = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(self.tracer.state, TrackingState.active) + ex.fulfill() + } + } + manager.completeActivation() wait(for: [ex], timeout: 1) } } diff --git a/Tests/DP3TSDKTests/Mocks/MockENManager.swift b/Tests/DP3TSDKTests/Mocks/MockENManager.swift new file mode 100644 index 00000000..0696aca2 --- /dev/null +++ b/Tests/DP3TSDKTests/Mocks/MockENManager.swift @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@testable import DP3TSDK +import Foundation +import ExposureNotification + +class MockENManager: ENManager { + var activateCallbacks: [ENErrorHandler] = [] + + var isEnabled = false { + willSet { + willChangeValue(for: \.exposureNotificationEnabled) + } + didSet { + didChangeValue(for: \.exposureNotificationEnabled) + } + } + + var status: ENStatus = .unknown { + willSet { + willChangeValue(for: \.exposureNotificationStatus) + } + didSet { + didChangeValue(for: \.exposureNotificationStatus) + } + } + + static var authStatus: ENAuthorizationStatus = .unknown + + var detectExposuresWasCalled = false + + var data: [Data] = [] + + var summary = MockSummary() + + override func detectExposures(configuration _: ENExposureConfiguration, diagnosisKeyURLs: [URL], completionHandler: @escaping ENDetectExposuresHandler) -> Progress { + detectExposuresWasCalled = true + completionHandler(summary, nil) + diagnosisKeyURLs.forEach { + let diagData = try! Data(contentsOf: $0) + data.append(diagData) + } + return Progress() + } + + override func activate(completionHandler: @escaping ENErrorHandler) { + activateCallbacks.append(completionHandler) + } + + override func setExposureNotificationEnabled(_ enabled: Bool, completionHandler: @escaping ENErrorHandler) { + self.isEnabled = enabled + self.status = .active + Self.authStatus = .authorized + completionHandler(nil) + } + + func completeActivation(){ + activateCallbacks.forEach{ $0(nil)} + activateCallbacks.removeAll() + } + + override var exposureNotificationEnabled: Bool { + isEnabled + } + + override var exposureNotificationStatus: ENStatus { + status + } + + override class var authorizationStatus: ENAuthorizationStatus { + Self.authStatus + } +} + +class MockSummary: ENExposureDetectionSummary { + override var attenuationDurations: [NSNumber] { + get { + internalAttenutationDurations + } + set { + internalAttenutationDurations = newValue + } + } + + private var internalAttenutationDurations: [NSNumber] = [0, 0, 0] +} From bc7a66864305823690c75a822a1c0c06d2a0986e Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Mon, 29 Jun 2020 10:37:26 +0200 Subject: [PATCH 06/10] adds more unit tests --- .../Tracing/ExposureNotificationTracer.swift | 13 +- Tests/DP3TSDKTests/DP3TSDKTests.swift | 53 ++++++-- .../ExposureNotificationMatcherTests.swift | 42 +------ .../ExposureNotificationTracerTests.swift | 114 +++++++++++++++++- Tests/DP3TSDKTests/Mocks/MockENManager.swift | 94 +++++++++++++++ 5 files changed, 265 insertions(+), 51 deletions(-) create mode 100644 Tests/DP3TSDKTests/Mocks/MockENManager.swift diff --git a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift index da109215..3bbb0f6d 100644 --- a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift +++ b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift @@ -26,6 +26,8 @@ class ExposureNotificationTracer: Tracer { private let logger = Logger(ExposureNotificationTracer.self, category: "exposureNotificationTracer") + private let managerClass: ENManager.Type + private(set) var state: TrackingState { didSet { guard oldValue != state else { return } @@ -34,8 +36,9 @@ class ExposureNotificationTracer: Tracer { } } - init(manager: ENManager) { + init(manager: ENManager, managerClass: ENManager.Type = ENManager.self) { self.manager = manager + self.managerClass = managerClass state = .initialization @@ -49,7 +52,9 @@ class ExposureNotificationTracer: Tracer { self.initializeObservers() } self.logger.log("notify callbacks after initialisation (count: %d)", self.initializationCallbacks.count) - self.initializationCallbacks.forEach { $0() } + self.initializationCallbacks.forEach { + DispatchQueue.main.async(execute: $0) + } self.initializationCallbacks.removeAll() } } @@ -63,7 +68,7 @@ class ExposureNotificationTracer: Tracer { queue.sync { self.logger.trace() guard self.state == .initialization else { - callback() + DispatchQueue.main.async(execute: callback) return } initializationCallbacks.append(callback) @@ -93,7 +98,7 @@ class ExposureNotificationTracer: Tracer { func updateState() { state = .init(state: manager.exposureNotificationStatus, - authorizationStatus: ENManager.authorizationStatus, + authorizationStatus: managerClass.authorizationStatus, enabled: manager.exposureNotificationEnabled) } diff --git a/Tests/DP3TSDKTests/DP3TSDKTests.swift b/Tests/DP3TSDKTests/DP3TSDKTests.swift index 820ed096..9aef1e2c 100644 --- a/Tests/DP3TSDKTests/DP3TSDKTests.swift +++ b/Tests/DP3TSDKTests/DP3TSDKTests.swift @@ -46,21 +46,29 @@ private class MockKeyProvider: DiagnosisKeysProvider { class DP3TSDKTests: XCTestCase { fileprivate var keychain: MockKeychain! - fileprivate var tracer: MockTracer! - fileprivate var matcher: MockMatcher! + fileprivate var tracer: ExposureNotificationTracer! + fileprivate var matcher: ExposureNotificationMatcher! fileprivate var service: MockService! fileprivate var defaults: MockDefaults! fileprivate var keyProvider: MockKeyProvider! fileprivate var descriptor: ApplicationDescriptor! fileprivate var backgroundTaskManager: DP3TBackgroundTaskManager! + fileprivate var manager: MockENManager! + fileprivate var exposureDayStorage: ExposureDayStorage! fileprivate var sdk: DP3TSDK! override func setUp() { keychain = MockKeychain() - tracer = MockTracer() - matcher = MockMatcher() - service = MockService() + + manager = MockENManager() + + exposureDayStorage = ExposureDayStorage(keychain: keychain) + defaults = MockDefaults() + tracer = ExposureNotificationTracer(manager: manager, managerClass: MockENManager.self) + matcher = ExposureNotificationMatcher(manager: manager, exposureDayStorage: exposureDayStorage, defaults: defaults) + + service = MockService() keyProvider = MockKeyProvider() descriptor = ApplicationDescriptor(appId: "org.dpppt", bucketBaseUrl: URL(string: "http://google.com")!, reportBaseUrl: URL(string: "http://google.com")!) backgroundTaskManager = DP3TBackgroundTaskManager(handler: nil, keyProvider: keyProvider, serviceClient: service) @@ -69,7 +77,7 @@ class DP3TSDKTests: XCTestCase { tracer: tracer, matcher: matcher, diagnosisKeysProvider: keyProvider, - exposureDayStorage: ExposureDayStorage(keychain: keychain), + exposureDayStorage: exposureDayStorage, outstandingPublishesStorage: OutstandingPublishStorage(keychain: keychain), service: service, synchronizer: KnownCasesSynchronizer(matcher: matcher, service: service, defaults: defaults, descriptor: descriptor), @@ -97,7 +105,7 @@ class DP3TSDKTests: XCTestCase { exp.fulfill() } wait(for: [exp], timeout: 0.1) - XCTAssert(tracer.isEnabled) + XCTAssertEqual(tracer.state, TrackingState.active) } func testInfected(){ @@ -161,4 +169,35 @@ class DP3TSDKTests: XCTestCase { XCTAssertThrowsError(try sdk.startTracing()) } + + func testSyncDontCompleteBeforeInit(){ + let exp = expectation(description: "sync") + exp.isInverted = true + sdk.sync(runningInBackground: false) { (result) in + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) + } + + func testSyncCompleteAfterInit(){ + let exp = expectation(description: "sync") + sdk.sync(runningInBackground: false) { (result) in + exp.fulfill() + } + manager.completeActivation() + wait(for: [exp], timeout: 0.1) + } + + func testSyncWhenActive(){ + let exp = expectation(description: "sync") + sdk.sync(runningInBackground: false) { (result) in + exp.fulfill() + } + manager.status = .active + MockENManager.authStatus = .authorized + manager.isEnabled = true + manager.completeActivation() + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(service.requests.count, 10) + } } diff --git a/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift b/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift index 065fdbc7..741a3c36 100644 --- a/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift +++ b/Tests/DP3TSDKTests/ExposureNotificationMatcherTests.swift @@ -11,42 +11,10 @@ import Foundation @testable import DP3TSDK -import ExposureNotification import Foundation import XCTest import ZIPFoundation -private class MockSummary: ENExposureDetectionSummary { - override var attenuationDurations: [NSNumber] { - get { - internalAttenutationDurations - } - set { - internalAttenutationDurations = newValue - } - } - - private var internalAttenutationDurations: [NSNumber] = [0, 0, 0] -} - -private class MockManager: ENManager { - var detectExposuresWasCalled = false - - var data: [Data] = [] - - var summary = MockSummary() - - override func detectExposures(configuration _: ENExposureConfiguration, diagnosisKeyURLs: [URL], completionHandler: @escaping ENDetectExposuresHandler) -> Progress { - detectExposuresWasCalled = true - completionHandler(summary, nil) - diagnosisKeyURLs.forEach { - let diagData = try! Data(contentsOf: $0) - data.append(diagData) - } - return Progress() - } -} - final class ExposureNotificationMatcherTests: XCTestCase { var keychain = MockKeychain() @@ -55,7 +23,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testCallingOfMatcher() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage) @@ -70,7 +38,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testCallingMatcherMultithreaded() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage) @@ -85,7 +53,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatch() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) @@ -104,7 +72,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatchFirstBucketOnly() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) @@ -124,7 +92,7 @@ final class ExposureNotificationMatcherTests: XCTestCase { } func testDetectingMatchSecondBucketOnly() { - let mockmanager = MockManager() + let mockmanager = MockENManager() let storage = ExposureDayStorage(keychain: keychain) let defaults = MockDefaults() let matcher = ExposureNotificationMatcher(manager: mockmanager, exposureDayStorage: storage, defaults: defaults) diff --git a/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift index d1f1449e..a6cab55c 100644 --- a/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift +++ b/Tests/DP3TSDKTests/ExposureNotificationTracerTests.swift @@ -10,16 +10,124 @@ @testable import DP3TSDK import XCTest -import ExposureNotification class ExposureNotificationTracerTests: XCTestCase { + + var manager: MockENManager! + var tracer: ExposureNotificationTracer! + + override func setUp() { + self.manager = MockENManager() + self.tracer = ExposureNotificationTracer(manager: manager, managerClass: MockENManager.self) + } + func testCallingCallbacks() { - let manager = ENManager() - let tracer = ExposureNotificationTracer(manager: manager) let ex = expectation(description: "init") tracer.addInitialisationCallback { ex.fulfill() } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testCallingCallbacksAfterActivate() { + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + + let ex1 = expectation(description: "afterInit") + tracer.addInitialisationCallback { + ex1.fulfill() + } + wait(for: [ex1], timeout: 1) + } + + func testStatusActive(){ + manager.status = .active + MockENManager.authStatus = .authorized + manager.isEnabled = true + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.active) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusStopped(){ + manager.status = .active + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.stopped) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusInctiveBluetoothOff(){ + manager.status = .bluetoothOff + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .bluetoothTurnedOff)) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusPermission(){ + manager.status = .restricted + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .permissonError)) + + ex.fulfill() + } + manager.completeActivation() + wait(for: [ex], timeout: 1) + } + + func testStatusInitialisation(){ + XCTAssertEqual(tracer.state, TrackingState.initialization) + } + + func testKVOStatusUpdate(){ + manager.status = .restricted + MockENManager.authStatus = .authorized + manager.isEnabled = false + + let ex = expectation(description: "init") + tracer.addInitialisationCallback { + + XCTAssertEqual(self.tracer.state, TrackingState.inactive(error: .permissonError)) + + self.manager.status = .active + self.manager.isEnabled = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(self.tracer.state, TrackingState.active) + ex.fulfill() + } + } + manager.completeActivation() wait(for: [ex], timeout: 1) } } diff --git a/Tests/DP3TSDKTests/Mocks/MockENManager.swift b/Tests/DP3TSDKTests/Mocks/MockENManager.swift new file mode 100644 index 00000000..0696aca2 --- /dev/null +++ b/Tests/DP3TSDKTests/Mocks/MockENManager.swift @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@testable import DP3TSDK +import Foundation +import ExposureNotification + +class MockENManager: ENManager { + var activateCallbacks: [ENErrorHandler] = [] + + var isEnabled = false { + willSet { + willChangeValue(for: \.exposureNotificationEnabled) + } + didSet { + didChangeValue(for: \.exposureNotificationEnabled) + } + } + + var status: ENStatus = .unknown { + willSet { + willChangeValue(for: \.exposureNotificationStatus) + } + didSet { + didChangeValue(for: \.exposureNotificationStatus) + } + } + + static var authStatus: ENAuthorizationStatus = .unknown + + var detectExposuresWasCalled = false + + var data: [Data] = [] + + var summary = MockSummary() + + override func detectExposures(configuration _: ENExposureConfiguration, diagnosisKeyURLs: [URL], completionHandler: @escaping ENDetectExposuresHandler) -> Progress { + detectExposuresWasCalled = true + completionHandler(summary, nil) + diagnosisKeyURLs.forEach { + let diagData = try! Data(contentsOf: $0) + data.append(diagData) + } + return Progress() + } + + override func activate(completionHandler: @escaping ENErrorHandler) { + activateCallbacks.append(completionHandler) + } + + override func setExposureNotificationEnabled(_ enabled: Bool, completionHandler: @escaping ENErrorHandler) { + self.isEnabled = enabled + self.status = .active + Self.authStatus = .authorized + completionHandler(nil) + } + + func completeActivation(){ + activateCallbacks.forEach{ $0(nil)} + activateCallbacks.removeAll() + } + + override var exposureNotificationEnabled: Bool { + isEnabled + } + + override var exposureNotificationStatus: ENStatus { + status + } + + override class var authorizationStatus: ENAuthorizationStatus { + Self.authStatus + } +} + +class MockSummary: ENExposureDetectionSummary { + override var attenuationDurations: [NSNumber] { + get { + internalAttenutationDurations + } + set { + internalAttenutationDurations = newValue + } + } + + private var internalAttenutationDurations: [NSNumber] = [0, 0, 0] +} From 56ae39afd75a9ca6c5061238e71db381a603c582 Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Wed, 1 Jul 2020 08:58:28 +0200 Subject: [PATCH 07/10] defer background task scheduling until app enters background --- .../DP3TBackgroundTaskManager.swift | 23 +++++++++++++++---- Sources/DP3TSDK/DP3TSDK.swift | 2 +- .../Tracing/ExposureNotificationTracer.swift | 2 ++ Sources/DP3TSDK/Tracing/TracerProtocols.swift | 2 ++ Tests/DP3TSDKTests/DP3TSDKTests.swift | 4 +++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/DP3TSDK/Background/DP3TBackgroundTaskManager.swift b/Sources/DP3TSDK/Background/DP3TBackgroundTaskManager.swift index 8c4fbcf5..a3ff9920 100644 --- a/Sources/DP3TSDK/Background/DP3TBackgroundTaskManager.swift +++ b/Sources/DP3TSDK/Background/DP3TBackgroundTaskManager.swift @@ -27,20 +27,27 @@ class DP3TBackgroundTaskManager { private let serviceClient: ExposeeServiceClientProtocol + private let tracer: Tracer + init(handler: DP3TBackgroundHandler?, keyProvider: DiagnosisKeysProvider, - serviceClient: ExposeeServiceClientProtocol) { + serviceClient: ExposeeServiceClientProtocol, + tracer: Tracer) { self.handler = handler self.keyProvider = keyProvider self.serviceClient = serviceClient + self.tracer = tracer + + NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) } /// Register a background task func register() { logger.trace() - defer { - scheduleBackgroundTask() - } guard !Self.didRegisterBackgroundTask else { return } Self.didRegisterBackgroundTask = true @@ -49,6 +56,10 @@ class DP3TBackgroundTaskManager { } } + @objc func appDidEnterBackground(){ + scheduleBackgroundTask() + } + private func handleBackgroundTask(_ task: BGTask) { logger.trace() @@ -95,6 +106,10 @@ class DP3TBackgroundTaskManager { private func scheduleBackgroundTask() { logger.trace() + guard tracer.isAuthorized else { + logger.log("skipping schedule because ENManager is not authorized") + return + } let taskRequest = BGProcessingTaskRequest(identifier: DP3TBackgroundTaskManager.taskIdentifier) taskRequest.requiresNetworkConnectivity = true do { diff --git a/Sources/DP3TSDK/DP3TSDK.swift b/Sources/DP3TSDK/DP3TSDK.swift index 0e562d7f..00c8a447 100644 --- a/Sources/DP3TSDK/DP3TSDK.swift +++ b/Sources/DP3TSDK/DP3TSDK.swift @@ -91,7 +91,7 @@ class DP3TSDK { let synchronizer = KnownCasesSynchronizer(matcher: matcher, service: service, descriptor: applicationDescriptor) - let backgroundTaskManager = DP3TBackgroundTaskManager(handler: backgroundHandler, keyProvider: manager, serviceClient: service) + let backgroundTaskManager = DP3TBackgroundTaskManager(handler: backgroundHandler, keyProvider: manager, serviceClient: service, tracer: tracer) self.init(applicationDescriptor: applicationDescriptor, urlSession: urlSession, diff --git a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift index ebb9e494..76add4f7 100644 --- a/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift +++ b/Sources/DP3TSDK/Tracing/ExposureNotificationTracer.swift @@ -55,6 +55,8 @@ class ExposureNotificationTracer: Tracer { NotificationCenter.default.removeObserver(self) } + var isAuthorized: Bool { ENManager.authorizationStatus == .authorized } + @objc func willEnterForeground(){ updateState() } diff --git a/Sources/DP3TSDK/Tracing/TracerProtocols.swift b/Sources/DP3TSDK/Tracing/TracerProtocols.swift index 06a397f5..64240676 100644 --- a/Sources/DP3TSDK/Tracing/TracerProtocols.swift +++ b/Sources/DP3TSDK/Tracing/TracerProtocols.swift @@ -19,5 +19,7 @@ protocol Tracer { var state: TrackingState { get } + var isAuthorized: Bool { get } + func setEnabled(_ enabled: Bool, completionHandler: ((Error?) -> Void)?) } diff --git a/Tests/DP3TSDKTests/DP3TSDKTests.swift b/Tests/DP3TSDKTests/DP3TSDKTests.swift index f1228ee7..4fee4271 100644 --- a/Tests/DP3TSDKTests/DP3TSDKTests.swift +++ b/Tests/DP3TSDKTests/DP3TSDKTests.swift @@ -14,6 +14,8 @@ import Foundation import XCTest private class MockTracer: Tracer { + var isAuthorized: Bool = true + var delegate: TracerDelegate? var state: TrackingState = .active @@ -58,7 +60,7 @@ class DP3TSDKTests: XCTestCase { defaults = MockDefaults() keyProvider = MockKeyProvider() descriptor = ApplicationDescriptor(appId: "org.dpppt", bucketBaseUrl: URL(string: "http://google.com")!, reportBaseUrl: URL(string: "http://google.com")!) - backgroundTaskManager = DP3TBackgroundTaskManager(handler: nil, keyProvider: keyProvider, serviceClient: service) + backgroundTaskManager = DP3TBackgroundTaskManager(handler: nil, keyProvider: keyProvider, serviceClient: service, tracer: tracer) sdk = DP3TSDK(applicationDescriptor: descriptor, urlSession: MockSession(data: nil, urlResponse: nil, error: nil), tracer: tracer, From b5878705e42476cfa05b101b4f6d667bc582ba78 Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Thu, 2 Jul 2020 14:47:57 +0200 Subject: [PATCH 08/10] update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9aaf0161..928260bb 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ DP3TTracing.iWasExposed(onset: Date(), authentication: .none) { result in ``` ### Sync with backend for exposed user -The SDK does not automatically sync with the backend for new exposed users. The app is responsible for fetching the new exposed users as it sees fit (periodically or via user input): +The SDK automatically syncs with the backend for new exposed users by scheduling a background task. ```swift DP3TTracing.sync() { result in // Handle result here @@ -169,6 +169,8 @@ The SDK supports iOS 13 Background tasks. To enable them the app has to support ``` +If a DP3TBackgroundHandler was passed to the SDK on initialisation it will be called on each background task execution by the SDK. + ## License This project is licensed under the terms of the MPL 2 license. See the [LICENSE](LICENSE) file. From 90603c9453f75bc64657713c18fa91faaf2bcbab Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Thu, 2 Jul 2020 16:38:29 +0200 Subject: [PATCH 09/10] dont store sync date if sync was skipped --- Sources/DP3TSDK/DP3TSDK.swift | 4 +- Sources/DP3TSDK/DP3TTracingState.swift | 16 +++++- .../Networking/KnownCasesSynchronizer.swift | 8 ++- .../KnownCasesSynchronizerTests.swift | 57 +++++++++++++++---- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/Sources/DP3TSDK/DP3TSDK.swift b/Sources/DP3TSDK/DP3TSDK.swift index f977dc10..27c46e73 100644 --- a/Sources/DP3TSDK/DP3TSDK.swift +++ b/Sources/DP3TSDK/DP3TSDK.swift @@ -194,7 +194,7 @@ class DP3TSDK { } group.enter() - var storedResult: Result? + var storedResult: SyncResult? self.synchronizer.sync { result in storedResult = result group.leave() @@ -205,6 +205,8 @@ class DP3TSDK { case .success: self.state.lastSync = Date() callback?(.success) + case .skipped: + callback?(.skipped) case let .failure(error): callback?(.failure(error)) } diff --git a/Sources/DP3TSDK/DP3TTracingState.swift b/Sources/DP3TSDK/DP3TTracingState.swift index 5ad56b4f..f9f03a56 100644 --- a/Sources/DP3TSDK/DP3TTracingState.swift +++ b/Sources/DP3TSDK/DP3TTracingState.swift @@ -72,11 +72,23 @@ public struct TracingState { } /// Result of a sync -public enum SyncResult { +public enum SyncResult: Equatable { /// Sync was successful case success /// An error occured case failure(_ error: DP3TTracingError) - /// tracing is not active / sdk is still be in initialization phase + /// tracing is not active / sdk is still be in initialization phase / sync is defered due to ratelimit case skipped + + public static func == (lhs: SyncResult, rhs: SyncResult) -> Bool { + switch (lhs, rhs) { + case (.success, .success), + (.skipped, .skipped): + return true + case let (.failure(lhsError), .failure(rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } } diff --git a/Sources/DP3TSDK/Networking/KnownCasesSynchronizer.swift b/Sources/DP3TSDK/Networking/KnownCasesSynchronizer.swift index e5074d2f..ed970326 100644 --- a/Sources/DP3TSDK/Networking/KnownCasesSynchronizer.swift +++ b/Sources/DP3TSDK/Networking/KnownCasesSynchronizer.swift @@ -68,7 +68,7 @@ class KnownCasesSynchronizer { } /// A callback result of async operations - typealias Callback = (Result) -> Void + typealias Callback = (SyncResult) -> Void /// Synchronizes the local database with the remote one /// - Parameters: @@ -244,7 +244,11 @@ class KnownCasesSynchronizer { callback?(.failure(lastError)) } else { self.logger.log("finishing sync successful") - callback?(.success(())) + if totalNumberOfRequests != 0 { + callback?(.success) + } else { + callback?(.skipped) + } } DP3TTracing.activityDelegate?.syncCompleted(totalRequest: totalNumberOfRequests, errors: occuredErrors) diff --git a/Tests/DP3TSDKTests/KnownCasesSynchronizerTests.swift b/Tests/DP3TSDKTests/KnownCasesSynchronizerTests.swift index 878aa7fd..cc2247a0 100644 --- a/Tests/DP3TSDKTests/KnownCasesSynchronizerTests.swift +++ b/Tests/DP3TSDKTests/KnownCasesSynchronizerTests.swift @@ -25,7 +25,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let now = Self.formatter.date(from: "19.05.2020 09:00")! let expecation = expectation(description: "syncExpectation") - sync.sync(now: now) { _ in + sync.sync(now: now) { res in + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -44,7 +45,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { res in + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -63,14 +65,19 @@ final class KnownCasesSynchronizerTests: XCTestCase { descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let today = DayDate().dayMin + var suceesses = 0 for i in 0 ..< 24 * 4 { let time = today.addingTimeInterval(Double(i) * TimeInterval.hour / 4) let expecation = expectation(description: "syncExpectation") - sync.sync(now: time) { _ in + sync.sync(now: time) { res in + if res == .success { + suceesses += 1 + } expecation.fulfill() } waitForExpectations(timeout: 1) } + XCTAssertEqual(suceesses, 2) XCTAssertEqual(matcher.timesCalledReceivedNewData, 20) } @@ -106,7 +113,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { res in + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -124,7 +132,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!.addingTimeInterval(.day * 15)) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!.addingTimeInterval(.day * 15)) { res in + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -143,7 +152,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: .init(timeIntervalSinceNow: .hour)) { _ in + sync.sync(now: .init(timeIntervalSinceNow: .hour)) { res in + XCTAssertEqual(res, SyncResult.failure(.networkingError(error: service.error!))) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -161,7 +171,27 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: .init(timeIntervalSinceNow: .hour)) { _ in + sync.sync(now: .init(timeIntervalSinceNow: .hour)) { res in + XCTAssertEqual(res, SyncResult.failure(.bluetoothTurnedOff)) + expecation.fulfill() + } + waitForExpectations(timeout: 1) + + XCTAssert(defaults.lastSyncTimestamps.isEmpty) + } + + func testDontStoreLastSyncSkipped() { + let matcher = MockMatcher() + let service = MockService() + let defaults = MockDefaults() + let sync = KnownCasesSynchronizer(matcher: matcher, + service: service, + defaults: defaults, + descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) + let expecation = expectation(description: "syncExpectation") + let now = Self.formatter.date(from: "19.05.2020 01:00")! + sync.sync(now: now) { res in + XCTAssertEqual(res, SyncResult.skipped) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -178,7 +208,9 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { res in + + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } waitForExpectations(timeout: 1) @@ -189,7 +221,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { service.requests = [] let secondExpectation = expectation(description: "secondSyncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!.addingTimeInterval(.hour + .day)) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!.addingTimeInterval(.hour + .day)) { res in + XCTAssertEqual(res, SyncResult.success) secondExpectation.fulfill() } waitForExpectations(timeout: 1) @@ -212,7 +245,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { expecation.expectedFulfillmentCount = iterations DispatchQueue.concurrentPerform(iterations: iterations) { _ in - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { res in + XCTAssertEqual(res, SyncResult.success) expecation.fulfill() } } @@ -265,7 +299,8 @@ final class KnownCasesSynchronizerTests: XCTestCase { defaults: defaults, descriptor: .init(appId: "ch.dpppt", bucketBaseUrl: URL(string: "http://www.google.de")!, reportBaseUrl: URL(string: "http://www.google.de")!)) let expecation = expectation(description: "syncExpectation") - sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { _ in + sync.sync(now: Self.formatter.date(from: "19.05.2020 09:00")!) { res in + XCTAssertEqual(res, SyncResult.failure(.networkingError(error: service.error!))) expecation.fulfill() } waitForExpectations(timeout: 1) From c37bfac2870067ad4d4425c8cf51cb49551c54fb Mon Sep 17 00:00:00 2001 From: Stefan Mitterrutzner Date: Fri, 3 Jul 2020 11:35:05 +0200 Subject: [PATCH 10/10] update version --- CHANGELOG.md | 5 +++++ DP3TSDK.podspec | 2 +- README.md | 2 +- Sources/DP3TSDK/DP3TTracing.swift | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b67157a3..1eb17ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog for DP3T-SDK iOS +## Version 1.0.2 (03.07.2020) +- defers sync until ENManager is fully initialized +- fixes in background task handling +- fix in storing of lastSync Date + ## Version 1.0.1 (22.06.2020) - Make timeshift detection independent from locale / region settings - Update last sync timestamps of individual days that were successful even if some others failed diff --git a/DP3TSDK.podspec b/DP3TSDK.podspec index 127f2fb7..87efcfc2 100644 --- a/DP3TSDK.podspec +++ b/DP3TSDK.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |spec| spec.name = "DP3TSDK" - spec.version = "1.0.1" + spec.version = "1.0.2" spec.summary = "Open protocol for COVID-19 proximity tracing using Bluetooth Low Energy on mobile devices" spec.description = <<-DESC diff --git a/README.md b/README.md index 928260bb..7d595d17 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ DP3T-SDK is available through [Cocoapods](https://cocoapods.org/) ```ruby - pod 'DP3TSDK', => '1.0.1' + pod 'DP3TSDK', => '1.0.2' ``` diff --git a/Sources/DP3TSDK/DP3TTracing.swift b/Sources/DP3TSDK/DP3TTracing.swift index 1352ff1b..e78ddb3d 100644 --- a/Sources/DP3TSDK/DP3TTracing.swift +++ b/Sources/DP3TSDK/DP3TTracing.swift @@ -28,7 +28,7 @@ private var instance: DP3TSDK! /// DP3TTracing public enum DP3TTracing { /// The current version of the SDK - public static let frameworkVersion: String = "1.0.1" + public static let frameworkVersion: String = "1.0.2" /// sets global parameter values which are used throughout the sdk public static var parameters: DP3TParameters {