├── images ├── efficacy.png └── epidemiology.png ├── Herald-for-iOS ├── Assets.xcassets │ ├── Contents.json │ ├── herald.imageset │ │ ├── herald.png │ │ └── Contents.json │ └── AppIcon.appiconset │ │ ├── Icon-20.png │ │ ├── Icon-60.png │ │ ├── Icon-76.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-76@2x.png │ │ ├── Icon-Small.png │ │ ├── virus_icon.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── Icon-Small@2x-1.png │ │ ├── Icon-iPadPro@2x.png │ │ ├── Icon-Spotlight-40.png │ │ ├── Icon-Spotlight-41.png │ │ ├── Icon-Spotlight-42.png │ │ ├── Icon-Spotlight-40@2x.png │ │ ├── Icon-Spotlight-40@3x.png │ │ ├── Icon-Spotlight-40@2x-1.png │ │ ├── Icon-Spotlight-40@3x-1.png │ │ └── Contents.json ├── VenueDiaryEventCell.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── VenueDiaryViewController.swift ├── ModeSelectionViewController.swift ├── Info.plist ├── Log.swift ├── VenueModeViewController.swift └── TargetDetailsViewController.swift ├── Herald ├── HeraldTests │ ├── XCTestManifests.swift │ ├── BeaconPayloadDataSupplierTests.swift │ ├── Info.plist │ ├── ExtendedDataTests.swift │ ├── SampleTests.swift │ ├── BloomFilterTests.swift │ ├── IntegrityTests.swift │ ├── PseudoDeviceAddressTests.swift │ ├── TextFileTests.swift │ ├── EncryptionTests.swift │ ├── PublicAPITests.swift │ ├── SimplePayloadDataMatcherTests.swift │ ├── SelfCalibratedModelTests.swift │ ├── KeyExchangeTests.swift │ ├── SocialDistanceTests.swift │ ├── TransportLayerSecurityTests.swift │ ├── BLEAdvertParserTests.swift │ └── VenueDiaryEventTests.swift ├── Herald │ ├── Sensor │ │ ├── Data │ │ │ ├── Resettable.swift │ │ │ ├── PayloadDataFormatter.swift │ │ │ ├── Security │ │ │ │ ├── Integrity.swift │ │ │ │ ├── PseudoRandomFunction.swift │ │ │ │ └── Encryption.swift │ │ │ ├── CalibrationLog.swift │ │ │ ├── BatteryLog.swift │ │ │ ├── ContactLog.swift │ │ │ ├── StatisticsLog.swift │ │ │ ├── DetectionLog.swift │ │ │ ├── SensorDelegateLogger.swift │ │ │ ├── SensorLogger.swift │ │ │ ├── EventTimeIntervalLog.swift │ │ │ ├── EventLog.swift │ │ │ └── TextFile.swift │ │ ├── Extensions │ │ │ └── DateExtensions.swift │ │ ├── PayloadDataMatcher.swift │ │ ├── Sensor.swift │ │ ├── Device.swift │ │ ├── Datatype │ │ │ ├── DoubleValue.swift │ │ │ └── RingBuffer.swift │ │ ├── Analysis │ │ │ ├── Sampling │ │ │ │ ├── AnalysisRunner.swift │ │ │ │ ├── ListManager.swift │ │ │ │ ├── AnalysisProvider.swift │ │ │ │ ├── Sample.swift │ │ │ │ ├── AnalysisDelegate.swift │ │ │ │ ├── VariantSet.swift │ │ │ │ └── Filter.swift │ │ │ ├── Mobility.swift │ │ │ ├── Algorithms │ │ │ │ ├── Distance │ │ │ │ │ └── FowlerBasic.swift │ │ │ │ └── Risk │ │ │ │ │ └── RiskAggregationBasic.swift │ │ │ └── SocialDistance.swift │ │ ├── Payload │ │ │ ├── Simple │ │ │ │ ├── SimplePayloadDataMatcher.swift │ │ │ │ └── BloomFilter.swift │ │ │ ├── Test │ │ │ │ └── TestPayloadDataSupplier.swift │ │ │ ├── Beacon │ │ │ │ └── BeaconPayloadDataSupplier.swift │ │ │ └── Extended │ │ │ │ └── ExtendedData.swift │ │ ├── Engine │ │ │ ├── Coordinator.swift │ │ │ └── Activities.swift │ │ ├── Motion │ │ │ └── InertiaSensor.swift │ │ ├── BLE │ │ │ ├── BLEUtilities.swift │ │ │ └── BLEAdvertParser.swift │ │ └── SensorArray.swift │ ├── herald.h │ └── Info.plist ├── Herald.xctestplan └── Herald.xcodeproj │ └── xcshareddata │ └── xcschemes │ └── Herald.xcscheme ├── AUTHORS.md ├── NOTICE.txt ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── bug_report.md │ └── security_report.md ├── workflows │ ├── publish.yml │ └── unit_tests.yml └── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── SECURITY.md ├── Herald.xcodeproj ├── HeraldTests_Info.plist └── Herald_Info.plist ├── Package.swift ├── Herald.podspec ├── contributing.md ├── .gitignore ├── Herald-for-iOS.xcodeproj └── xcshareddata │ └── xcschemes │ └── Herald-for-iOS.xcscheme └── README.md /images/efficacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/images/efficacy.png -------------------------------------------------------------------------------- /images/epidemiology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/images/epidemiology.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/herald.imageset/herald.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/herald.imageset/herald.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/virus_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/virus_icon.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theheraldproject/herald-for-ios/HEAD/Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x-1.png -------------------------------------------------------------------------------- /Herald/HeraldTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(HeraldTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | ## Project Leads 4 | 5 | Adam Fowler - TSC Chair 2020-2021 - https://github.com/adamfowleruk - adam@adamfowler.org 6 | 7 | ## Developers 8 | 9 | TBD - please join in! If making a PR include your author information here. 10 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/Resettable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Resettable.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Resettable component 11 | public protocol Resettable { 12 | 13 | // Resets component 14 | func reset() 15 | } 16 | -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/herald.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "herald.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | herald-for-ios 2 | Copyright 2020-2021 Herald Project Contributors 3 | 4 | This product is licensed to you under the Apache-2.0 license (the "License"). You may not use this product except in compliance with the Apache-2.0 License. 5 | 6 | This product may include a number of subcomponents with separate copyright notices and license terms. Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, as noted in the LICENSE file. 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Herald project website 4 | url: https://heraldprox.io/ 5 | about: Our main project website for information 6 | - name: Developer and integration guides 7 | url: https://heraldprox.io/guide/ 8 | about: Please review this guide for how to integrate Herald with your application 9 | url: https://heraldprox.io/community 10 | about: Please join the Herald community to ask questions and get involved with development -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Extensions/DateExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateExtensions.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | 12 | var secondsSinceUnixEpoch: Int64 { get { Int64(floor(timeIntervalSince1970)) }} 13 | 14 | static func - (lhs: Date, rhs: Date) -> TimeInterval { 15 | return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Herald/Herald.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "1279809A-A20D-4E98-A218-7028A578CD01", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:Herald.xcodeproj", 18 | "identifier" : "B650151A25229D480070F774", 19 | "name" : "HeraldTests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | To report issues in live software, or upstream dependencies of our project's software, 4 | or to make a responsible disclosure, 5 | please email the project TSC Chair at adam@adamfowler.org. 6 | 7 | This shall be routed to the relevant maintainers of this open source project. 8 | 9 | To report a general concern around security please fill in a new issue 10 | using the 'security report' template on our GitHub Issues page for this project. 11 | 12 | https://github.com/theheraldproject/herald-for-ios/issues/new 13 | -------------------------------------------------------------------------------- /Herald/Herald/herald.h: -------------------------------------------------------------------------------- 1 | // 2 | // Herald.h 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for Herald. 11 | FOUNDATION_EXPORT double HeraldVersionNumber; 12 | 13 | //! Project version string for Herald. 14 | FOUNDATION_EXPORT const unsigned char HeraldVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/PayloadDataMatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayloadDataMatcher.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Payload data matcher for testing whether payload data exists in a matching set, e.g. keys associated with infectious users. 11 | protocol PayloadDataMatcher { 12 | 13 | /// Test if payload data captured at a specific time is in the matching set. 14 | func matches(_ timestamp: PayloadTimestamp, _ data: PayloadData) -> Bool 15 | } 16 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/PayloadDataFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayloadDataFormatter.swift 3 | // 4 | // Copyright 2021-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol PayloadDataFormatter { 11 | func shortFormat(_ payloadData: PayloadData) -> String 12 | } 13 | 14 | public struct ConcretePayloadDataFormatter : PayloadDataFormatter { 15 | public init() {} 16 | 17 | public func shortFormat(_ payloadData: PayloadData) -> String { 18 | return payloadData.shortName 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to CocoaPods 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: [macos-latest] 11 | permissions: 12 | contents: read 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Install cocoapods 17 | run: gem install cocoapods 18 | 19 | - name: Publish to CocoaPods 20 | run: | 21 | set -eo pipefail 22 | pod lib lint --allow-warnings 23 | pod trunk push Herald.podspec --allow-warnings 24 | env: 25 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Sensor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sensor.swift 3 | // 4 | // Copyright 2020-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Sensor for detecting and tracking various kinds of disease transmission vectors, e.g. contact with people, time at location. 11 | public protocol Sensor { 12 | /// Add delegate for responding to sensor events. 13 | func add(delegate: SensorDelegate) 14 | 15 | /// Start sensing. 16 | func start() 17 | 18 | /// Stop sensing. 19 | func stop() 20 | 21 | /// Retrieve a CoordinationProvider 22 | func coordinationProvider() -> CoordinationProvider? 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Device.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Device.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class Device : NSObject { 11 | /// Device registratiion timestamp 12 | var createdAt: Date 13 | /// Last time anything changed, e.g. attribute update 14 | var lastUpdatedAt: Date 15 | 16 | /// Ephemeral device identifier, e.g. peripheral identifier UUID 17 | public var identifier: TargetIdentifier 18 | 19 | init(_ identifier: TargetIdentifier) { 20 | self.createdAt = Date() 21 | self.identifier = identifier 22 | lastUpdatedAt = createdAt 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/Security/Integrity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Integrity.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import CommonCrypto 10 | 11 | /// Cryptographically secure hash function 12 | public protocol Integrity { 13 | 14 | func hash(_ data: Data) -> Data 15 | } 16 | 17 | /// SHA256 cryptographic hash function 18 | /// NCSC Foundation Profile for TLS requires integrity check using SHA-256 19 | public class SHA256: Integrity { 20 | 21 | public func hash(_ data: Data) -> Data { 22 | var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 23 | data.withUnsafeBytes({ _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) }) 24 | return Data(hash) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Herald/HeraldTests/BeaconPayloadDataSupplierTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeaconPayloadDataSupplierTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class BeaconPayloadDataSupplierTests: XCTestCase { 12 | 13 | func testBasicFormat() throws { 14 | let beacon = ConcreteBeaconPayloadDataSupplierV1(countryCode: 0xDDEE, stateCode: 0x11FF, code: 0xAABBCCDD) 15 | let expected : Data = Data.init(base64Encoded: "MO7d/xHdzLuq")! // Hex = "30EEDDFF11DDCCBBAA" 16 | let beaconPayload : PayloadData? = beacon.payload(device: nil) 17 | XCTAssertNotNil(beaconPayload) 18 | XCTAssertEqual(expected.base64EncodedString(), beaconPayload!.base64EncodedString()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Herald/HeraldTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Herald/HeraldTests/ExtendedDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtendedDataTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class ExtendedDataTests: XCTestCase { 12 | 13 | func testMultipleSections() throws { 14 | let extendedData = ConcreteExtendedDataV1(); 15 | 16 | let section1Code = UInt8(0x01); 17 | let section1Value = UInt8(3); 18 | extendedData.addSection(code: section1Code, value: section1Value) 19 | 20 | let section2Code = UInt8(0x02); 21 | let section2Value = UInt16(25); 22 | extendedData.addSection(code: section2Code, value: section2Value) 23 | 24 | XCTAssertEqual(2, extendedData.getSections().count) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Herald.xcodeproj/HeraldTests_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundleDevelopmentRegion 5 | en 6 | CFBundleExecutable 7 | $(EXECUTABLE_NAME) 8 | CFBundleIdentifier 9 | $(PRODUCT_BUNDLE_IDENTIFIER) 10 | CFBundleInfoDictionaryVersion 11 | 6.0 12 | CFBundleName 13 | $(PRODUCT_NAME) 14 | CFBundlePackageType 15 | BNDL 16 | CFBundleShortVersionString 17 | 1.0 18 | CFBundleSignature 19 | ???? 20 | CFBundleVersion 21 | $(CURRENT_PROJECT_VERSION) 22 | NSPrincipalClass 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Herald", 8 | platforms: [ 9 | .iOS(.v9) 10 | ], 11 | products: [ 12 | .library( 13 | name: "Herald", 14 | targets: [ 15 | "Herald" 16 | ] 17 | ) 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Herald", 22 | path: "Herald/Herald" 23 | ), 24 | .testTarget( 25 | name: "HeraldTests", 26 | dependencies: [ 27 | "Herald" 28 | ], 29 | path: "Herald/HeraldTests" 30 | ) 31 | ], 32 | swiftLanguageVersions: [ 33 | .v5 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Herald/Herald/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Herald/HeraldTests/SampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | 9 | 10 | import XCTest 11 | @testable import Herald 12 | 13 | class SampleTests: XCTestCase { 14 | 15 | private class Value: DoubleValue { 16 | } 17 | 18 | func testSample() { 19 | let s1 = Sample(taken: Date(timeIntervalSince1970: 1), value: Value(1)) 20 | let s1Copy = Sample(taken: Date(timeIntervalSince1970: 1), value: Value(1)) 21 | let s2 = Sample(taken: Date(timeIntervalSince1970: 2), value: Value(2)) 22 | 23 | XCTAssertEqual(s1.taken, s1Copy.taken) 24 | XCTAssertEqual(s1.value.value, s1Copy.value.value) 25 | XCTAssertNotEqual(s1.taken, s2.taken) 26 | XCTAssertNotEqual(s1.value.value, s2.value.value) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Herald.xcodeproj/Herald_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Please start with a use case description for a USER of this enhancement** 11 | 12 | [Who] As a ????? [e.g. Developer / App user] 13 | 14 | [What] I need to ??? [e.g. see a list of venues] 15 | 16 | [Value] In order to achieve ??? and/or realise ??? benefit 17 | 18 | **Describe the potential solution you'd like** 19 | 20 | A clear and concise description of what you want to happen within Herald. 21 | 22 | **Describe alternatives you've considered** 23 | 24 | A clear and concise description of any alternative solutions or features you've considered. 25 | 26 | **Additional context** 27 | 28 | Add any other context or screenshots about the feature request here. 29 | 30 | **Relative priority** 31 | 32 | Please give an indication of a relative priority for this enhancement. -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Datatype/DoubleValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DoubleValue.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Generic mutable double value 11 | public class DoubleValue: CustomStringConvertible { 12 | public var value: Double 13 | public var description: String { get { value.description }} 14 | 15 | public init(_ value: Double) { 16 | self.value = value 17 | } 18 | 19 | public init(_ value: Int) { 20 | self.value = Double(value) 21 | } 22 | } 23 | 24 | /// Received signal strength indicator (RSSI) 25 | public class RSSI: DoubleValue { 26 | public override var description: String { get { "RSSI{value=\(value.description)}" }} 27 | } 28 | 29 | /// Physical distance in metres 30 | public class Distance: DoubleValue { 31 | public override var description: String { get { "Distance{value=\(value.description)}" }} 32 | } 33 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request 3 | about: Submit a pull request 4 | title: 'Pull request' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Realted to # . 11 | 12 | or 13 | 14 | Fixes # . 15 | 16 | Changes proposed in this pull request:- 17 | - ? 18 | - ? 19 | - ? 20 | 21 | Signed-off-by: Firstname Surname 22 | 23 | **Checklist prior to submission**:- 24 | 25 | - [ ] Have you added a line to **EVERY** commit to this PR with Signed-off-by: Forename Surname ? (This is required for us to be able to merge your contributions) 26 | - See the CONTRIBUTING.md file in this repository for information as to why this is important. 27 | - [ ] Have you Linked to a known feature/bug ID via 'Related to' or 'Fixes', above? 28 | - [ ] (Optional best practice) Is your branch named feature-ISSUENUM ? (See GitFlow for why this is relevant) 29 | 30 | 31 | **Notification** 32 | 33 | Please do not edit the below. It will notify the maintainers once you submit your PR. 34 | 35 | @theheraldproject/committers 36 | -------------------------------------------------------------------------------- /Herald/HeraldTests/BloomFilterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BloomFilterTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class BloomFilterTests: XCTestCase { 12 | 13 | func test() { 14 | let bloomFilter = BloomFilter(1024 * 1024) 15 | // Add even numbers to bloom filter 16 | for i in 0...10000 { 17 | var data = Data() 18 | data.append(Int32(i * 2)) 19 | bloomFilter.add(data) 20 | } 21 | // Test even numbers are all contained in bloom filter 22 | for i in 0...10000 { 23 | var data = Data() 24 | data.append(Int32(i * 2)) 25 | XCTAssertTrue(bloomFilter.contains(data)) 26 | } 27 | // Confirm odd numbers are not in bloom filter 28 | for i in 0...10000 { 29 | var data = Data() 30 | data.append(Int32(i * 2 + 1)) 31 | XCTAssertFalse(bloomFilter.contains(data)) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/AnalysisRunner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalysisRunner.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class AnalysisRunner: CallableForNewSample { 11 | private let analysisProviderManager: AnalysisProviderManager 12 | private let analysisDelegateManager: AnalysisDelegateManager 13 | public let variantSet: VariantSet 14 | 15 | public init(_ analysisProviderManager: AnalysisProviderManager, _ analysisDelegateManager: AnalysisDelegateManager, defaultListSize: Int) { 16 | self.analysisProviderManager = analysisProviderManager 17 | self.analysisDelegateManager = analysisDelegateManager 18 | self.variantSet = VariantSet(defaultListSize) 19 | } 20 | 21 | public override func newSample(sampled: SampledID, item: Sample) { 22 | variantSet.push(sampled, item) 23 | } 24 | 25 | public func run(timeNow: Date = Date()) { 26 | for sampled in variantSet.sampledIDs() { 27 | let _ = analysisProviderManager.analyse(timeNow: timeNow, sampled: sampled, variantSet: variantSet, delegates: analysisDelegateManager) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Herald/HeraldTests/IntegrityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntegrityTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class IntegrityTests: XCTestCase { 12 | 13 | public func testHash() { 14 | let integrity: Integrity = SHA256() 15 | for i in 0...99 { 16 | let hashA = integrity.hash(Data(repeating: UInt8(i), count: i)) 17 | let hashB = integrity.hash(Data(repeating: UInt8(i), count: i)) 18 | XCTAssertEqual(hashA, hashB) 19 | } 20 | } 21 | 22 | public func testCrossPlatform() { 23 | let integrity: Integrity = SHA256() 24 | var csv = "key,value\n" 25 | for i in 0...99 { 26 | let hashA = integrity.hash(Data(repeating: UInt8(i), count: i)) 27 | let hashB = integrity.hash(Data(repeating: UInt8(i), count: i)) 28 | XCTAssertEqual(hashA, hashB) 29 | csv.append("\(i),\(hashA.hexEncodedString)\n") 30 | } 31 | let attachment = XCTAttachment(string: csv) 32 | attachment.lifetime = .keepAlways 33 | attachment.name = "integrity.csv" 34 | add(attachment) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | # READ THIS BEFORE MODIFYING: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 4 | # And this: https://nathandavison.com/blog/github-actions-and-the-threat-of-malicious-pull-requests 5 | 6 | # WARNING ONLY EVER USE A GITHUB_TOKEN THAT HAS **ONLY** READ_REPO ACCESS 7 | # (Because we invoke the local gradlew command for reproducibility) 8 | 9 | # read-only repo token 10 | # no access to secrets 11 | on: 12 | pull_request: 13 | branches: 14 | - develop # PRs from external developers or project team 15 | - main # PRs for triggering a release 16 | 17 | jobs: 18 | unit_tests: 19 | name: Swift Build and Test for ${{ matrix.destination }} 20 | runs-on: [macos-latest] 21 | strategy: 22 | matrix: 23 | destination: [ 24 | 'platform=iOS Simulator,OS=16.2,name=iPhone 11 Pro' 25 | ] 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | 30 | - name: Build 31 | run: | 32 | xcodebuild clean test -project Herald-for-iOS.xcodeproj -scheme Herald -destination "${destination}" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO 33 | env: 34 | destination: ${{ matrix.destination }} -------------------------------------------------------------------------------- /Herald/HeraldTests/PseudoDeviceAddressTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PseudoDeviceAddressTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | 9 | 10 | import XCTest 11 | @testable import Herald 12 | 13 | class PseudoDeviceAddressTests: XCTestCase { 14 | 15 | func testCrossPlatform() throws { 16 | // Zero, Min, Max 17 | // Values in range 18 | var csv = "value,data\n" 19 | var i:Int64 = 1 20 | while i <= (Int64.max / 7) { 21 | let dataPositive = BLEPseudoDeviceAddress(value: i) 22 | XCTAssertEqual(dataPositive.address, BLEPseudoDeviceAddress(data: dataPositive.data)?.address) 23 | csv.append("\(i),\(dataPositive.data.base64EncodedString())\n") 24 | let dataNegative = BLEPseudoDeviceAddress(value: -i) 25 | XCTAssertEqual(dataNegative.address, BLEPseudoDeviceAddress(data: dataNegative.data)?.address) 26 | csv.append("\(-i),\(dataNegative.data.base64EncodedString())\n") 27 | i *= 7 28 | } 29 | let attachment = XCTAttachment(string: csv) 30 | attachment.lifetime = .keepAlways 31 | attachment.name = "pseudoDeviceAddress.csv" 32 | add(attachment) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/CalibrationLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalibrationLog.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// CSV contact log for post event analysis and visualisation 11 | class CalibrationLog: SensorDelegateLogger { 12 | 13 | public override init(filename: String) { 14 | super.init(filename: filename) 15 | } 16 | 17 | private func writeHeader() { 18 | if empty() { 19 | write("time,payload,rssi,x,y,z") 20 | } 21 | } 22 | 23 | // MARK:- SensorDelegate 24 | 25 | override func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier, withPayload: PayloadData) { 26 | writeHeader() 27 | write(timestamp() + "," + csv(withPayload.shortName) + "," + csv(didMeasure.value.description) + ",,,") 28 | } 29 | 30 | override func sensor(_ sensor: SensorType, didVisit: Location?) { 31 | guard let didVisit = didVisit, let reference = didVisit.value as? InertiaLocationReference else { 32 | return 33 | } 34 | let timestamp = dateFormatter.string(from: didVisit.time.start) 35 | writeHeader() 36 | write(timestamp + ",,," + reference.x.description + "," + reference.y.description + "," + reference.z.description) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Herald.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | 3 | spec.name = "Herald" 4 | spec.version = "2.2.0" 5 | spec.summary = "Reliable Bluetooth communication library for iOS" 6 | 7 | spec.description = <<-DESC 8 | Herald provides reliable Bluetooth communication and range finding across a wide range of mobile devices, allowing Contact Tracing and other applications to have regular and accurate information to make them highly effective. 9 | 10 | In addition, the Herald community defines suggested payloads to be exchanged over the Herald protocol for a range of applications, both contact tracing payloads (centralised, decentralised, and hybrid) and beyond (E.g. Bluetooth MESH to Consumer app gateway applications.) 11 | 12 | Herald supports iOS, Android, and embedded devices. 13 | DESC 14 | 15 | spec.homepage = "https://heraldprox.io/" 16 | spec.license = { :type => "Apache-2.0", :file => "LICENSE.txt" } 17 | spec.author = { "adamfowleruk" => "adam@adamfowler.org" } 18 | 19 | spec.ios.deployment_target = "9.3" 20 | spec.swift_version = "5" 21 | 22 | spec.source = { :git => "https://github.com/theheraldproject/herald-for-ios.git", :tag => "v#{spec.version}" } 23 | spec.source_files = "Herald/Herald/**/*.{h,m,swift}" 24 | 25 | pod_target_xcconfig = { "EXCLUDED_ARCHS[sdk=iphonesimulator*]" => "arm64" } 26 | user_target_xcconfig = { "EXCLUDED_ARCHS[sdk=iphonesimulator*]" => "arm64" } 27 | 28 | end 29 | -------------------------------------------------------------------------------- /Herald/HeraldTests/TextFileTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFileTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class TextFileTests: XCTestCase { 12 | 13 | func testEmpty() { 14 | let textFile = TextFile(filename: "empty.txt") 15 | XCTAssertTrue(textFile.empty()) 16 | XCTAssertEqual(textFile.contentsOf(), "") 17 | } 18 | 19 | func testWriteOneLine() { 20 | let textFile = TextFile(filename: "oneLine.txt") 21 | textFile.overwrite("") 22 | textFile.write("line1") 23 | XCTAssertFalse(textFile.empty()) 24 | XCTAssertEqual(textFile.contentsOf(), "line1\n") 25 | } 26 | 27 | func testWriteTwoLines() { 28 | let textFile = TextFile(filename: "twoLine.txt") 29 | textFile.overwrite("") 30 | textFile.write("line1") 31 | textFile.write("line2") 32 | XCTAssertFalse(textFile.empty()) 33 | XCTAssertEqual(textFile.contentsOf(), "line1\nline2\n") 34 | } 35 | 36 | func testOverwrite() { 37 | let textFile = TextFile(filename: "overwrite.txt") 38 | textFile.overwrite("") 39 | XCTAssertTrue(textFile.empty()) 40 | textFile.overwrite("line1") 41 | XCTAssertEqual(textFile.contentsOf(), "line1") 42 | textFile.overwrite("line2") 43 | XCTAssertEqual(textFile.contentsOf(), "line2") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/ListManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListManager.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class ListManager { 11 | private let queue = DispatchQueue(label: "Sensor.Analysis.Sampling.ListManager") 12 | private let listSize: Int 13 | private var map: [SampledID:SampleList] = [:] 14 | 15 | public init(_ listSize: Int) { 16 | self.listSize = listSize 17 | } 18 | 19 | public func list(_ listFor: SampledID) -> SampleList { 20 | queue.sync { 21 | if let list = map[listFor] { 22 | return list 23 | } else { 24 | let list = SampleList(listSize) 25 | map[listFor] = list 26 | return list 27 | } 28 | } 29 | } 30 | 31 | public func sampledIDs() -> Set { 32 | queue.sync { 33 | return Set(map.keys) 34 | } 35 | } 36 | 37 | public func remove(_ listFor: SampledID) { 38 | queue.sync { 39 | let _ = map.removeValue(forKey: listFor) 40 | } 41 | } 42 | 43 | public func size() -> Int { 44 | queue.sync { 45 | return map.count 46 | } 47 | } 48 | 49 | public func clear() { 50 | queue.sync { 51 | map.removeAll() 52 | } 53 | } 54 | 55 | public func push(_ sampledID: SampledID, _ sample: Sample) { 56 | list(sampledID).push(sample: sample) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Herald-for-iOS/VenueDiaryEventCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VenueDiaryEventCell.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | // 8 | 9 | import UIKit 10 | import Herald 11 | 12 | public class VenueDiaryEventCell: UITableViewCell { 13 | private let dateFormatter = DateFormatter() 14 | private let dateFormatterTime = DateFormatter() 15 | 16 | @IBOutlet weak var venueName: UILabel! 17 | @IBOutlet weak var venueCode: UILabel! 18 | @IBOutlet weak var checkInDate: UILabel! 19 | @IBOutlet weak var checkInTime: UILabel! 20 | @IBOutlet weak var checkOutTime: UILabel! 21 | 22 | public func display(_ evt: VenueDiaryEvent) { 23 | dateFormatter.dateFormat = "dd MMM" 24 | dateFormatterTime.dateFormat = "HH:mm" 25 | 26 | let first = evt.getFirstTime() 27 | let last = evt.getLastTime() 28 | checkInDate.text = dateFormatter.string(from: first) 29 | checkInTime.text = dateFormatterTime.string(from: first) 30 | if first == last { 31 | checkOutTime.text = "N/A" 32 | } else { 33 | // are we closed yet? 34 | if evt.isClosed() { 35 | checkOutTime.text = dateFormatterTime.string(from: last) 36 | } else { 37 | checkOutTime.text = dateFormatterTime.string(from: last) + "..." 38 | } 39 | } 40 | 41 | venueCode.text = "\(evt.getCode())" 42 | 43 | if let name = evt.getName() { 44 | venueName.text = name 45 | } else { 46 | venueName.text = "Unknown name" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Payload/Simple/SimplePayloadDataMatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SonarPayloadDataIdentifier.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Simple payload matcher for matching contact identifier in payload data against matching keys 11 | protocol SimplePayloadDataMatcher : PayloadDataMatcher { 12 | } 13 | 14 | class ConcreteSimplePayloadDataMatcher : SimplePayloadDataMatcher { 15 | private var bloomFilters: [Int:BloomFilter] = [:] 16 | 17 | /// Create matcher for matching contact identifiers against identifiers associated with matching keys 18 | init(_ matchingKeys: [Date:[MatchingKey]]) { 19 | matchingKeys.forEach { date, matchingKeysOnDate in 20 | let day = K.day(date) 21 | let bloomFilter = BloomFilter(1024*1024*8) 22 | bloomFilters[day] = bloomFilter 23 | matchingKeysOnDate.forEach { matchingKey in 24 | K.forEachContactIdentifier(matchingKey) { contactIdentifier, _ in 25 | bloomFilter.add(contactIdentifier) 26 | } 27 | } 28 | } 29 | } 30 | 31 | // MARK:- SimplePayloadDataMatcher 32 | 33 | func matches(_ timestamp: PayloadTimestamp, _ data: PayloadData) -> Bool { 34 | let day = K.day(timestamp) 35 | guard let bloomFilter = bloomFilters[day] else { 36 | return false 37 | } 38 | let contactIdentifier = ContactIdentifier(data.subdata(in: 5.. 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/BatteryLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BatteryLog.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import UIKit 9 | import NotificationCenter 10 | import os 11 | 12 | /// Battery log for monitoring battery level over time 13 | public class BatteryLog: SensorDelegateLogger { 14 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "BatteryLog") 15 | private let updateInterval = TimeInterval(30) 16 | 17 | public override init(filename: String) { 18 | super.init(filename: filename) 19 | UIDevice.current.isBatteryMonitoringEnabled = true 20 | NotificationCenter.default.addObserver(self, selector: #selector(batteryLevelDidChange), name: UIDevice.batteryLevelDidChangeNotification, object: nil) 21 | NotificationCenter.default.addObserver(self, selector: #selector(batteryStateDidChange), name: UIDevice.batteryStateDidChangeNotification, object: nil) 22 | let _ = Timer.scheduledTimer(timeInterval: updateInterval, target: self, selector: #selector(update), userInfo: nil, repeats: true) 23 | } 24 | 25 | private func writeHeader() { 26 | if empty() { 27 | write("time,source,level") 28 | } 29 | } 30 | 31 | @objc func update() { 32 | let powerSource = (UIDevice.current.batteryState == .unplugged ? "battery" : "external") 33 | let batteryLevel = Float(UIDevice.current.batteryLevel * 100).description 34 | write(timestamp() + "," + powerSource + "," + batteryLevel) 35 | logger.debug("update (powerSource=\(powerSource),batteryLevel=\(batteryLevel))"); 36 | } 37 | 38 | @objc func batteryLevelDidChange(_ sender: NotificationCenter) { 39 | update() 40 | } 41 | 42 | @objc func batteryStateDidChange(_ sender: NotificationCenter) { 43 | update() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Engine/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // 4 | // Copyright 2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | // Created by Adam Fowler on 07/02/2023. 8 | // 9 | 10 | import Foundation 11 | 12 | /// 13 | /// Provides timed action collection and coordination based upon the concept of Features and Providers 14 | /// 15 | //class Coordinator: NSObject { 16 | // private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Engine.Coordinator") 17 | // private var running: Bool = false 18 | // private var providers: [CoordinationProvider] = [] 19 | // private var featureProviders: [FeatureTag : CoordinationProvider] = [:] 20 | // 21 | // func add(sensor: Sensor) { 22 | // if let provider = sensor.coordinationProvider() { 23 | // providers.append(provider) 24 | // } 25 | // } 26 | // 27 | // func remove(sensor: Sensor) { 28 | // 29 | // } 30 | // 31 | // func start() { 32 | // featureProviders.removeAll() 33 | // for provider in providers { 34 | // for feature in provider.connectionsProvided() { 35 | // featureProviders[feature] = provider 36 | // } 37 | // } 38 | // } 39 | // 40 | // func iteration() { 41 | // if !running { 42 | // logger.debug("Coordinator iteration called when running=false") 43 | // return 44 | // } 45 | // var assignPrereqs: [CoordinationProvider: [PrioritisedPrerequisite]] = [:] 46 | // var connsRequired: [PrioritisedPrerequisite] = [] 47 | // for provider in providers { 48 | // for conn in provider.requiredConnections() { 49 | // connsRequired.append(conn) 50 | // } 51 | // } 52 | // 53 | // for prereq in connsRequired { 54 | // var featureProvider: CoordinationProvider = featureProviders[PrioritisedPrerequisite] 55 | // } 56 | // } 57 | // 58 | // func stop() { 59 | // 60 | // } 61 | //} 62 | -------------------------------------------------------------------------------- /Herald-for-iOS/VenueDiaryViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VenueDiaryViewController.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import UIKit 9 | import Herald 10 | 11 | class VenueDiaryViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 12 | 13 | 14 | private let logger = Log(subsystem: "Herald", category: "VenueDiaryViewController") 15 | private let appDelegate = UIApplication.shared.delegate as! AppDelegate 16 | 17 | @IBOutlet weak var tableVenueDiary: UITableView! 18 | 19 | private var diary: VenueDiary? = nil 20 | 21 | // MARK:- UIViewController 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | tableVenueDiary.delegate = self 27 | tableVenueDiary.dataSource = self 28 | } 29 | 30 | // MARK:- instance methods 31 | 32 | public func setDiary(_ diary: VenueDiary) { 33 | self.diary = diary 34 | logger.debug("setDiary") 35 | tableVenueDiary.reloadData() 36 | } 37 | 38 | // MARK:- TableViewDataSource 39 | 40 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 41 | guard let diary = diary else { 42 | return 0 43 | } 44 | logger.debug("Diary checkin count: \(diary.eventListCount())") 45 | return diary.eventListCount() 46 | } 47 | 48 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 49 | 50 | let cell = tableView.dequeueReusableCell(withIdentifier: "venueDiaryCell", 51 | for: indexPath) as! VenueDiaryEventCell 52 | let events = diary!.listRecordableEvents() 53 | let evt = events[indexPath.row] 54 | cell.display(evt) 55 | 56 | return cell 57 | } 58 | 59 | func tableView(_ tableView: UITableView, 60 | heightForRowAt indexPath: IndexPath) -> CGFloat { 61 | return 80 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/Security/PseudoRandomFunction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PseudoRandomFunction.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol PseudoRandomFunction { 11 | 12 | /// Get next bytes from random function 13 | func nextBytes(_ data: inout Data) -> Bool 14 | } 15 | 16 | public extension PseudoRandomFunction { 17 | 18 | func nextBytes(_ count: Int) -> Data { 19 | var data = Data(repeating: 0, count: max(0, count)) 20 | if !nextBytes(&data) { 21 | let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Data.Security.PseudoRandomFunction") 22 | logger.fault("Random function failed, reverting to UInt8 random") 23 | for i in 0...data.count-1 { 24 | data[i] = UInt8.random(in: UInt8.min...UInt8.max) 25 | } 26 | } 27 | return data 28 | } 29 | 30 | func nextInt64() -> Int64 { 31 | return nextBytes(8).int64(0)! 32 | } 33 | } 34 | 35 | public class SecureRandomFunction: PseudoRandomFunction { 36 | 37 | public init() { 38 | } 39 | 40 | public func nextBytes(_ data: inout Data) -> Bool { 41 | var bytes = [UInt8](repeating: 0, count: data.count) 42 | let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 43 | guard errSecSuccess == status else { 44 | return false 45 | } 46 | withUnsafeMutablePointer(to: &data) { pointer in 47 | for i in 0...pointer.pointee.count-1 { 48 | pointer.pointee[i] = bytes[i] 49 | } 50 | } 51 | return true 52 | } 53 | } 54 | 55 | /// Test random source that produces the same ressult for all calls 56 | public class TestRandomFunction: PseudoRandomFunction { 57 | private let value: UInt8 58 | 59 | public init(_ value: UInt8 = UInt8.max) { 60 | self.value = value 61 | } 62 | 63 | public func nextBytes(_ data: inout Data) -> Bool { 64 | for i in 0...data.count - 1 { 65 | data[i] = value 66 | } 67 | return true 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /Herald/HeraldTests/PublicAPITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PublicAPITests.swift 3 | // 4 | // Copyright 2022 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | // Reimplementing Mean to test Aggregate's subclassing from another Module 12 | public class MyTestAggregate: Aggregate { 13 | public override var runs: Int { get { 1 }} 14 | private var run: Int = 1 15 | private var count: Int64 = 0 16 | private var sum: Double = 0 17 | 18 | public override func beginRun(thisRun: Int) { 19 | run = thisRun 20 | } 21 | 22 | public override func map(value: Sample) { 23 | guard run == 1 else { 24 | return 25 | } 26 | sum += value.value.value 27 | count += 1 28 | } 29 | 30 | public override func reduce() -> Double? { 31 | guard count > 0 else { 32 | return nil 33 | } 34 | return sum / Double(count) 35 | } 36 | 37 | public override func reset() { 38 | run = 1 39 | count = 0 40 | sum = 0 41 | } 42 | } 43 | 44 | // Test to replicate issue https://github.com/theheraldproject/herald-for-ios/issues/172 45 | // Note: Due to the Tests being compiled *WITHIN* the Herald product, this check 46 | // doesn't actually fail! 47 | // TODO: Determine how to add a separate project to perform external-API client tests too. 48 | class PublicAPITests: XCTestCase { 49 | 50 | public func test_publicapi_aggregate() { 51 | let srcData = SampleList(25) 52 | srcData.push(secondsSinceUnixEpoch: 0, value: RSSI(-66)) 53 | srcData.push(secondsSinceUnixEpoch: 10, value: RSSI(-66)) 54 | srcData.push(secondsSinceUnixEpoch: 20, value: RSSI(-68)) 55 | srcData.push(secondsSinceUnixEpoch: 30, value: RSSI(-68)) 56 | srcData.push(secondsSinceUnixEpoch: 40, value: RSSI(-70)) 57 | srcData.push(secondsSinceUnixEpoch: 50, value: RSSI(-70)) 58 | 59 | let mean = MyTestAggregate() 60 | 61 | // values = -60, -68, -68 62 | let summary = srcData.aggregate([mean]) 63 | XCTAssertEqual(summary.get(MyTestAggregate.self), -68) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Herald-for-iOS/ModeSelectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModeSelectionViewController.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import UIKit 9 | import Herald 10 | 11 | class ModeSelectionViewController: UIViewController, UIAdaptivePresentationControllerDelegate { 12 | private let logger = Log(subsystem: "Herald", category: "ModeSelectionViewController") 13 | 14 | private let appDelegate = UIApplication.shared.delegate as! AppDelegate 15 | 16 | override func viewDidLoad() { 17 | logger.debug("viewDidLoad") 18 | super.viewDidLoad() 19 | } 20 | 21 | override func viewDidAppear(_ animated: Bool) { 22 | self.logger.debug("viewDidAppear") 23 | } 24 | 25 | @IBAction func openVenueBeaconMode(_ sender: UIButton) { 26 | let mainStoryboard = UIStoryboard(name: "Main", bundle: Bundle.main) 27 | if let viewController = mainStoryboard.instantiateViewController(withIdentifier: "venuevc") as? UIViewController { 28 | self.present(viewController, animated: true, completion: { 29 | self.logger.debug("completion callback - venue") 30 | viewController.presentationController?.delegate = self 31 | }) 32 | } 33 | } 34 | 35 | @IBAction func openPhoneMode(_ sender: UIButton) { 36 | let mainStoryboard = UIStoryboard(name: "Main", bundle: Bundle.main) 37 | if let viewController = mainStoryboard.instantiateViewController(withIdentifier: "phonevc") as? UIViewController { 38 | self.present(viewController, animated: true, completion: { 39 | self.logger.debug("completion callback - phone") 40 | viewController.presentationController?.delegate = self 41 | }) 42 | } 43 | } 44 | 45 | /// MARK: UIAdaptivePresentationControllerDelegate 46 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { 47 | self.logger.debug("presentationControllerDidDismiss") 48 | // TODO ensure this doesn't get called if a popup from a subview if cancelled 49 | self.appDelegate.stopBluetooth() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Herald/HeraldTests/SimplePayloadDataMatcherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimplePayloadDataMatcherTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class SimplePayloadDataMatcherTests: XCTestCase { 12 | 13 | @available(iOS 13.0, *) 14 | func testMatches() throws { 15 | let ks1 = SecretKey(repeating: 0, count: 2048) 16 | let ks2 = SecretKey(repeating: 1, count: 2048) 17 | let pds1 = ConcreteSimplePayloadDataSupplier(protocolAndVersion: 0, countryCode: 0, stateCode: 0, secretKey: ks1) 18 | let pds2 = ConcreteSimplePayloadDataSupplier(protocolAndVersion: 0, countryCode: 0, stateCode: 0, secretKey: ks2) 19 | 20 | let day0 = K.date("2020-09-24T00:00:00+0000")! 21 | let day1 = K.date("2020-09-25T00:00:00+0000")! 22 | 23 | let pdm1 = ConcreteSimplePayloadDataMatcher([day0:[pds1.matchingKey(day0)!]]) 24 | 25 | for second in 0...(24*60*60)-1 { 26 | let time = day0.advanced(by: TimeInterval(second)) 27 | let payloadData1 = pds1.payload(time, device: nil) 28 | let payloadData2 = pds2.payload(time, device: nil) 29 | 30 | XCTAssertNotNil(payloadData1) 31 | XCTAssertNotNil(payloadData2) 32 | 33 | // Match should pass as secret key is the same 34 | XCTAssertTrue(pdm1.matches(time, payloadData1!)) 35 | // Match should fail as secret key is different 36 | XCTAssertFalse(pdm1.matches(time, payloadData2!)) 37 | } 38 | 39 | for second in 0...(24*60*60)-1 { 40 | let time = day1.advanced(by: TimeInterval(second)) 41 | let payloadData1 = pds1.payload(time, device: nil) 42 | let payloadData2 = pds2.payload(time, device: nil) 43 | 44 | XCTAssertNotNil(payloadData1) 45 | XCTAssertNotNil(payloadData2) 46 | 47 | // Match should fail as secret key is the same but day is different 48 | XCTAssertFalse(pdm1.matches(time, payloadData1!)) 49 | // Match should fail as secret key is different and day is different 50 | XCTAssertFalse(pdm1.matches(time, payloadData2!)) 51 | } 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/AnalysisProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalysisProvider.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class AnalysisProvider { 11 | public let inputType: ValueType 12 | public let outputType: ValueType 13 | 14 | public init(_ inputType: ValueType, _ outputType: ValueType) { 15 | self.inputType = inputType 16 | self.outputType = outputType 17 | } 18 | 19 | public func analyse(timeNow: Date, sampled: SampledID, input: SampleList, output: SampleList, callable: CallableForNewSample) -> Bool { 20 | return false 21 | } 22 | } 23 | 24 | 25 | 26 | public class AnalysisProviderManager { 27 | private var lists: [ValueType:[AnalysisProvider]] = [:] 28 | public var inputTypes: Set = Set() 29 | public var outputTypes: Set = Set() 30 | private var providers: [AnalysisProvider] = [] 31 | private let queue = DispatchQueue(label: "Sensor.Analysis.Sampling.AnalysisProviderManager") 32 | 33 | public init(_ providers: [AnalysisProvider] = []) { 34 | providers.forEach({ add($0) }) 35 | } 36 | 37 | public func add(_ provider: AnalysisProvider) { 38 | queue.sync { 39 | if var list = lists[provider.inputType] { 40 | list.append(provider) 41 | } else { 42 | lists[provider.inputType] = [provider] 43 | } 44 | inputTypes.insert(provider.inputType) 45 | outputTypes.insert(provider.outputType) 46 | providers.append(provider) 47 | } 48 | } 49 | 50 | public func analyse(timeNow: Date, sampled: SampledID, variantSet: VariantSet, delegates: AnalysisDelegateManager) -> Bool { 51 | var update = false 52 | for provider in providers { 53 | let input = variantSet.listManager(variant: provider.inputType, listFor: sampled) 54 | let output = variantSet.listManager(variant: provider.outputType, listFor: sampled) 55 | let hasUpdate = provider.analyse(timeNow: timeNow, sampled: sampled, input: input, output: output, callable: delegates) 56 | update = update || hasUpdate 57 | } 58 | return update 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/Sample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sample.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class Sample { 11 | public let taken:Date 12 | public let value:DoubleValue 13 | public let valueType: ValueType 14 | public var description: String { get { 15 | return "(" + taken.description + "," + String(describing: value) + ")" 16 | } } 17 | 18 | public init(taken: Date, value: DoubleValue) { 19 | self.taken = taken 20 | self.value = value 21 | self.valueType = ValueType(describing: type(of: value)) 22 | } 23 | 24 | public convenience init(timeIntervalSince1970: TimeInterval, value: DoubleValue) { 25 | self.init(taken: Date(timeIntervalSince1970: timeIntervalSince1970), value: value) 26 | } 27 | 28 | public convenience init(secondsSinceUnixEpoch: Int64, value: DoubleValue) { 29 | self.init(taken: Date(timeIntervalSince1970: TimeInterval(secondsSinceUnixEpoch)), value: value) 30 | } 31 | 32 | public convenience init(sample: Sample) { 33 | self.init(taken: sample.taken, value: sample.value) 34 | } 35 | 36 | public convenience init(value: DoubleValue) { 37 | self.init(taken: Date(), value: value) 38 | } 39 | } 40 | 41 | public class SampledID: Equatable, Comparable, Hashable, CustomStringConvertible { 42 | public let value: Int64 43 | public var description: String { get { value.description }} 44 | 45 | public init(_ value: Int64) { 46 | self.value = value 47 | } 48 | 49 | public init(_ data: Data) { 50 | var hashValue: [UInt8] = [0,0,0,0,0,0,0,0] 51 | for i in 0...data.count-1 { 52 | let j = i % 8 53 | hashValue[j] = hashValue[j] ^ data[i] 54 | } 55 | self.value = Data(hashValue).int64(0)! 56 | } 57 | 58 | public static func == (lhs: SampledID, rhs: SampledID) -> Bool { 59 | return lhs.value == rhs.value 60 | } 61 | 62 | public static func < (lhs: SampledID, rhs: SampledID) -> Bool { 63 | return lhs.value < rhs.value 64 | } 65 | 66 | public func hash(into hasher: inout Hasher) { 67 | hasher.combine(value) 68 | } 69 | } 70 | 71 | public typealias ValueType = String 72 | -------------------------------------------------------------------------------- /Herald/HeraldTests/SelfCalibratedModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelfCalibratedModelTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | 9 | 10 | import XCTest 11 | @testable import Herald 12 | 13 | class SelfCalibratedModelTests: XCTestCase { 14 | 15 | public func test_uncalibrated() { 16 | let model: SelfCalibratedModel = SelfCalibratedModel(min: Distance(0.2), mean: Distance(1), withinMin: TimeInterval.zero, withinMean: TimeInterval.hour * 12) 17 | 18 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(-10))) 19 | XCTAssertEqual(model.reduce()!, 0.2, accuracy: 0.1) 20 | 21 | model.reset() 22 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(-54))) 23 | XCTAssertEqual(model.reduce()!, 1.0, accuracy: 0.1) 24 | 25 | model.reset() 26 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(-99))) 27 | XCTAssertEqual(model.reduce()!, 1.8, accuracy: 0.1) 28 | } 29 | 30 | public func test_calibrated_range() { 31 | for minRssi in -99...(-15) { 32 | for maxRssi in (minRssi+4)...(-11) { 33 | print("test_calibrated_range[\(minRssi),\(maxRssi)]"); 34 | test_calibrated_range(minRssi, maxRssi) 35 | } 36 | } 37 | } 38 | 39 | private func test_calibrated_range(_ minRssi: Int, _ maxRssi: Int) { 40 | let midRssi = minRssi + (maxRssi - minRssi) / 2 41 | let quarterRssi = minRssi + (maxRssi - minRssi) * 3 / 4 42 | let model: SelfCalibratedModel = SelfCalibratedModel(min: Distance(0.2), mean: Distance(1), withinMin: TimeInterval.zero, withinMean: TimeInterval.hour * 12) 43 | for rssi in minRssi...maxRssi { 44 | model.histogram.add(rssi) 45 | } 46 | model.update() 47 | 48 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(maxRssi))) 49 | XCTAssertEqual(model.reduce()!, 0.2, accuracy: 0.1) 50 | 51 | model.reset() 52 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(quarterRssi))) 53 | XCTAssertEqual(model.reduce()!, 0.6, accuracy: 0.2) 54 | 55 | model.reset() 56 | model.map(value: Sample(secondsSinceUnixEpoch: 0, value: RSSI(midRssi))) 57 | XCTAssertEqual(model.reduce()!, 1.0, accuracy: 0.1) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/AnalysisDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalysisDelegate.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class AnalysisDelegate: CallableForNewSample { 11 | public let inputType: ValueType 12 | private let listManager: ListManager 13 | public let samples: SampleList 14 | 15 | public init(inputType: ValueType, listSize: Int) { 16 | self.inputType = inputType 17 | self.listManager = ListManager(listSize) 18 | self.samples = SampleList(listSize) 19 | } 20 | 21 | public convenience init(_ inputType: T.Type, listSize: Int) { 22 | self.init(inputType: ValueType(describing: inputType), listSize: listSize) 23 | } 24 | 25 | public func reset() { 26 | listManager.clear() 27 | } 28 | 29 | public func samples(sampledID: SampledID) -> SampleList { 30 | return listManager.list(sampledID) 31 | } 32 | 33 | public override func newSample(sampled: SampledID, item: Sample) { 34 | listManager.push(sampled, item) 35 | samples.push(sample: item) 36 | } 37 | } 38 | 39 | 40 | 41 | public class CallableForNewSample { 42 | 43 | public func newSample(sampled: SampledID, item: Sample) {} 44 | } 45 | 46 | 47 | 48 | public class AnalysisDelegateManager: CallableForNewSample { 49 | private var lists: [ValueType:[AnalysisDelegate]] = [:] 50 | private let queue = DispatchQueue(label: "Sensor.Analysis.Sampling.AnalysisDelegateManager") 51 | 52 | public init(_ delegates: [AnalysisDelegate] = []) { 53 | super.init() 54 | delegates.forEach({ add($0) }) 55 | } 56 | 57 | public func inputTypes() -> Set { 58 | return Set(lists.keys) 59 | } 60 | 61 | public func add(_ delegate: AnalysisDelegate) { 62 | queue.sync { 63 | if var list = lists[delegate.inputType] { 64 | list.append(delegate) 65 | } else { 66 | lists[delegate.inputType] = [delegate] 67 | } 68 | } 69 | } 70 | 71 | public override func newSample(sampled: SampledID, item: Sample) { 72 | guard let delegates = lists[item.valueType] else { 73 | return 74 | } 75 | delegates.forEach({ $0.newSample(sampled: sampled, item: item)}) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The Herald project team welcomes contributions from the community. If you wish to contribute code and you have not signed our developer certificate of origin (DCO), our bot will update the issue when you open a Pull Request. For any questions about the DCO process, please contact a project maintainer. 4 | 5 | This page presents guidelines for contributing to this repository. Following the guidelines helps to make the contribution process easy, collaborative, and productive. 6 | 7 | ## Inclusivity 8 | 9 | The Herald Project strives to include people who may have anxiety about working in opensource 10 | software and hardware or working in social teams. Some of our founders are neurodiverse. We 11 | want to encourage maximum participation in the opensource community. Project members can provide 12 | 1:1 pairing or advice to any new contributor. In particular we can offer a supportive environment 13 | to those who are ADD/ADHD or Autistic. Please contact us if you have always wanted to help in 14 | opensource but haven't because of concerns of fitting in - we can help! 15 | 16 | ## Submitting Bug Reports and Feature Requests 17 | 18 | Please submit bug reports and feature requests by using our GitHub [Issues](https://github.com/theheraldproject/herald-for-ios/issues) page. 19 | 20 | Before you submit a bug report about the code in the repository, please check the Issues page to see whether someone has already reported the problem. In the bug report, be as specific as possible about the error and the conditions under which it occurred. On what version and build did it occur? What are the steps to reproduce the bug? 21 | 22 | Feature requests should fall within the scope of the project. 23 | 24 | ## Pull Requests 25 | 26 | The Herald project team use GitFlow to manage contributions, with feature-ISSUENUM 27 | branches created off of the develop branch. 28 | 29 | Please read a guide on GitFlow before you start work on an issue, and add a comment 30 | to the issue you are working on so work is not duplicated. 31 | 32 | Please also ensure that every commit is signed-off (in text, not GPG key signing) as follows in each commit message:- 33 | 34 | `Signed-off-by: Joe Bloggs ` 35 | 36 | Note: The correct whitespace here is important. 37 | 38 | Please also add yourself to the AUTHORS.md file so your contribution is publicly recognised. 39 | 40 | Before submitting a pull request, please make sure that your modifications work for a variety of scenarios and the demo app. 41 | -------------------------------------------------------------------------------- /Herald-for-iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | NSBluetoothAlwaysUsageDescription 29 | Detect close contacts with other users to trace direct disease transmissions 30 | NSBluetoothPeripheralUsageDescription 31 | Detect close contacts with other users to trace direct disease transmissions 32 | NSLocationAlwaysAndWhenInUseUsageDescription 33 | Detect recently visited places and areas to trace indirect disease transmissions 34 | NSLocationWhenInUseUsageDescription 35 | Detect recently visited places and areas to trace indirect disease transmissions 36 | UIBackgroundModes 37 | 38 | bluetooth-central 39 | bluetooth-peripheral 40 | location 41 | 42 | UIFileSharingEnabled 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | accelerometer 52 | 53 | UISupportedInterfaceOrientations 54 | 55 | UIInterfaceOrientationPortrait 56 | 57 | UISupportedInterfaceOrientations~ipad 58 | 59 | UIInterfaceOrientationPortrait 60 | UIInterfaceOrientationPortraitUpsideDown 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Engine/Activities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Activities.swift 3 | // 4 | // Copyright 2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | // Created by Adam Fowler on 07/02/2023. 8 | // 9 | 10 | import Foundation 11 | 12 | public typealias FeatureTag = Data 13 | 14 | public let HeraldBluetoothProtocolConnection: FeatureTag = FeatureTag(repeating: UInt8(0x01), count: 1) 15 | 16 | public typealias Priority = UInt8 17 | 18 | public let CriticalPriority: Priority = Priority(200) 19 | public let HighPriority: Priority = Priority(150) 20 | public let DefaultPriority: Priority = Priority(100) 21 | public let LowPriority: Priority = Priority(50) 22 | 23 | public class Prerequisite { 24 | private var feature: FeatureTag 25 | private var relatedTo: TargetIdentifier? = nil 26 | 27 | init(required: FeatureTag) { 28 | feature = required 29 | } 30 | 31 | init(required: FeatureTag, toward: TargetIdentifier) { 32 | feature = required 33 | relatedTo = toward 34 | } 35 | 36 | func getRelatedTo() -> TargetIdentifier? { 37 | return relatedTo 38 | } 39 | 40 | func getFeature() -> FeatureTag { 41 | return feature 42 | } 43 | } 44 | 45 | public class PrioritisedPrerequisite: Prerequisite { 46 | private var priority: Priority = DefaultPriority 47 | 48 | override init(required: FeatureTag) { 49 | super.init(required: required) 50 | } 51 | 52 | override init(required: FeatureTag, toward: TargetIdentifier) { 53 | super.init(required: required, toward: toward) 54 | } 55 | 56 | init(required: FeatureTag, priority: Priority) { 57 | super.init(required: required) 58 | self.priority = priority 59 | } 60 | 61 | init(required: FeatureTag, toward: TargetIdentifier, priority: Priority) { 62 | super.init(required: required, toward: toward) 63 | self.priority = priority 64 | } 65 | } 66 | 67 | public struct ActivityDescription { 68 | var priority: Priority 69 | var name: String 70 | var prerequisities: [Prerequisite] 71 | } 72 | 73 | 74 | public protocol ActivityProvider { 75 | func executeActivity(activity: ActivityDescription) 76 | } 77 | 78 | public struct Activity { 79 | var description: ActivityDescription 80 | var executor: ActivityProvider 81 | } 82 | 83 | public protocol CoordinationProvider { 84 | func connectionsProvided() -> [FeatureTag] 85 | func provision(prereqs: [PrioritisedPrerequisite]) -> [PrioritisedPrerequisite] 86 | func requiredConnections() -> [PrioritisedPrerequisite] 87 | func requiredActivities() -> [Activity] 88 | } 89 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/VariantSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VariantSet.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class VariantSet { 11 | private let defaultListSize: Int 12 | private var map: [ValueType:ListManager] = [:] 13 | 14 | public init(_ defaultListSize: Int) { 15 | self.defaultListSize = defaultListSize 16 | } 17 | 18 | public func variants() -> Set { 19 | return Set(map.keys) 20 | } 21 | 22 | public func sampledIDs() -> Set { 23 | var sampledIDs: Set = Set() 24 | map.values.forEach({ sampledIDs.formUnion($0.sampledIDs()) }) 25 | return sampledIDs 26 | } 27 | 28 | public func add(variant: ValueType, listSize: Int) -> ListManager { 29 | let listManager = ListManager(listSize) 30 | let typeName = String(describing: variant) 31 | map[typeName] = listManager 32 | return listManager 33 | } 34 | 35 | public func remove(variant: ValueType) { 36 | let typeName = String(describing: variant) 37 | map.removeValue(forKey: typeName) 38 | } 39 | 40 | public func remove(_ type: T.Type) { 41 | remove(variant: ValueType(describing: type)) 42 | } 43 | 44 | public func remove(sampledID: SampledID) { 45 | map.values.forEach({ $0.remove(sampledID) }) 46 | } 47 | 48 | public func removeAll() { 49 | map.removeAll() 50 | } 51 | 52 | public func listManager(variant: ValueType) -> ListManager { 53 | let typeName = String(describing: variant) 54 | if let listManager = map[typeName] { 55 | return listManager 56 | } else { 57 | return add(variant: variant, listSize: defaultListSize) 58 | } 59 | } 60 | 61 | public func listManager(_ type: T.Type) -> ListManager { 62 | return listManager(variant: ValueType(describing: type)) 63 | } 64 | 65 | public func listManager(variant: ValueType, listFor: SampledID) -> SampleList { 66 | return listManager(variant: variant).list(listFor) 67 | } 68 | 69 | public func listManager(_ type: T.Type, _ listFor: SampledID) -> SampleList { 70 | return listManager(variant: ValueType(describing: type), listFor: listFor) 71 | } 72 | 73 | public func size() -> Int { 74 | return map.count 75 | } 76 | 77 | public func push(_ sampledID: SampledID, _ sample: Sample) { 78 | listManager(variant: sample.valueType).push(sampledID, sample) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Herald-for-iOS/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | /// Common log interface across supported iOS versions 12 | class Log: NSObject { 13 | /// Define log level acros all logger messages 14 | private let logLevel: LogLevel = .debug 15 | private let subsystem: String 16 | private let category: String 17 | private let dateFormatter = DateFormatter() 18 | private let log: OSLog? 19 | 20 | required init(subsystem: String, category: String) { 21 | self.subsystem = subsystem 22 | self.category = category 23 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 24 | if #available(iOS 10.0, *) { 25 | log = OSLog(subsystem: subsystem, category: category) 26 | } else { 27 | log = nil 28 | } 29 | } 30 | 31 | private func suppress(_ level: LogLevel) -> Bool { 32 | switch level { 33 | case .debug: 34 | return (logLevel == .info || logLevel == .fault); 35 | case .info: 36 | return (logLevel == .fault); 37 | default: 38 | return false; 39 | } 40 | } 41 | 42 | func log(_ level: LogLevel, _ message: String) { 43 | guard !suppress(level) else { 44 | return 45 | } 46 | // Write to unified os log if available, else print to console 47 | let timestamp = dateFormatter.string(from: Date()) 48 | let csvMessage = message.replacingOccurrences(of: "\"", with: "'") 49 | let quotedMessage = (message.contains(",") ? "\"" + csvMessage + "\"" : csvMessage) 50 | let entry = timestamp + "," + level.rawValue + "," + subsystem + "," + category + "," + quotedMessage 51 | guard let log = log else { 52 | print(entry) 53 | return 54 | } 55 | if #available(iOS 10.0, *) { 56 | switch (level) { 57 | case .debug: 58 | os_log("%s", log: log, type: .debug, message) 59 | case .info: 60 | os_log("%s", log: log, type: .info, message) 61 | case .fault: 62 | os_log("%s", log: log, type: .fault, message) 63 | } 64 | return 65 | } 66 | } 67 | 68 | func debug(_ message: String) { 69 | log(.debug, message) 70 | } 71 | 72 | func info(_ message: String) { 73 | log(.debug, message) 74 | } 75 | 76 | func fault(_ message: String) { 77 | log(.debug, message) 78 | } 79 | 80 | } 81 | 82 | /// Log level for messages 83 | enum LogLevel : String { 84 | case debug, info, fault 85 | } 86 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Payload/Test/TestPayloadDataSupplier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestPayloadDataSupplier.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | /// Test payload data supplier for generating fixed payload to support evaluation 12 | public protocol TestPayloadDataSupplier : PayloadDataSupplier { 13 | } 14 | 15 | public class ConcreteTestPayloadDataSupplier : TestPayloadDataSupplier { 16 | let length: Int 17 | let identifier: Int32 18 | 19 | public init(identifier: Int32, length: Int = 129) { 20 | self.identifier = identifier 21 | self.length = length 22 | } 23 | 24 | public func legacyPayload(_ timestamp: PayloadTimestamp = PayloadTimestamp(), device: Device?) -> LegacyPayloadData? { 25 | guard let device = device as? BLEDevice, let rssi = device.rssi, let payload = payload(timestamp, device: device), 26 | let service = UUID(uuidString: BLESensorConfiguration.interopOpenTraceServiceUUID.uuidString) else { 27 | return nil 28 | } 29 | do { 30 | let dataToWrite = CentralWriteDataV2( 31 | mc: deviceModel(), 32 | rs: Double(rssi), 33 | id: payload.base64EncodedString(), 34 | o: "OT_HA", 35 | v: 2) 36 | let encodedData = try JSONEncoder().encode(dataToWrite) 37 | let legacyPayloadData = LegacyPayloadData(service: service, data: encodedData) 38 | return legacyPayloadData 39 | } catch { 40 | } 41 | return nil 42 | } 43 | 44 | public func payload(_ timestamp: PayloadTimestamp = PayloadTimestamp(), device: Device?) -> PayloadData? { 45 | let payloadData = PayloadData() 46 | // First 1 byte = protocolAndVersion (UInt8) 47 | payloadData.append(UInt8(0)) 48 | // Next 2 bytes = countryCode (UInt16) 49 | payloadData.append(UInt16(0)) 50 | // Next 4 bytes are used for fixed cross-platform identifier (Int32) 51 | payloadData.append(Int32(identifier)) 52 | // Fill with blank data to make payload the same size expected length 53 | payloadData.append(Data(repeating: 0, count: length - payloadData.count)) 54 | return payloadData 55 | } 56 | 57 | private func deviceModel() -> String { 58 | var deviceInformation = utsname() 59 | uname(&deviceInformation) 60 | let mirror = Mirror(reflecting: deviceInformation.machine) 61 | return mirror.children.reduce("") { identifier, element in 62 | guard let value = element.value as? Int8, value != 0 else { 63 | return identifier 64 | } 65 | return identifier + String(UnicodeScalar(UInt8(value))) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Mobility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mobility.swift 3 | // 4 | // Copyright 2021-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Estimate distance travelled without recording actual locations visited to produce mobility indicator for 11 | /// prioritising work based on potential range of influence 12 | public class Mobility: EventLog { 13 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Analysis.Mobility") 14 | 15 | // MARK:- SensorDelegate 16 | 17 | public override func sensor(_ sensor: SensorType, didVisit: Location?) { 18 | guard sensor == .MOBILITY else { 19 | return 20 | } 21 | guard let didVisit = didVisit, let locationReference = didVisit.value as? MobilityLocationReference else { 22 | return 23 | } 24 | let event = MobilityEvent(locationReference.distance, timestamp: didVisit.time.end) 25 | logger.debug("didVisit(event=\(event))") 26 | append(event) 27 | } 28 | 29 | // MARK:- Analysis functions 30 | 31 | public func reduce(into timeWindow: TimeInterval) -> [(time: Date, distance: Distance)] { 32 | let timeWindows = super.reduce(into: timeWindow) 33 | return timeWindows.map({ ($0.time, $0.events.reduce(into: Distance(0), { total, event in total.value += event.distance.value })) }) 34 | } 35 | } 36 | 37 | /// Mobility record describing distance travelled 38 | public class MobilityEvent: Event { 39 | public static var csvHeader: String = "time,distance" 40 | public let timestamp: Date 41 | public let distance: Distance 42 | public var csvString: String { get { 43 | let dateFormatter = DateFormatter() 44 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 45 | let f0 = dateFormatter.string(from: timestamp) 46 | let f1 = String(round(distance.value)) 47 | return "\(f0),\(f1)" 48 | }} 49 | 50 | /// Create encounter instance from source data 51 | public init(_ distance: Distance, timestamp: Date = Date()) { 52 | self.timestamp = timestamp 53 | self.distance = distance 54 | } 55 | 56 | /// Create encounter instance from log entry 57 | required public init?(_ csvString: String) { 58 | let fields = csvString.split(separator: ",") 59 | guard fields.count >= 2 else { 60 | return nil 61 | } 62 | let dateFormatter = DateFormatter() 63 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 64 | guard let timestamp = dateFormatter.date(from: String(fields[0])) else { 65 | return nil 66 | } 67 | self.timestamp = timestamp 68 | guard let value = Double(String(fields[1])) else { 69 | return nil 70 | } 71 | self.distance = Distance(value) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Herald-for-iOS/VenueModeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VenueModeViewController.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import UIKit 9 | import Herald 10 | 11 | class VenueModeViewController: UIViewController, UIPickerViewDelegate, UIPickerViewDataSource { 12 | private let logger = Log(subsystem: "Herald", category: "VenueModeViewController") 13 | private let appDelegate = UIApplication.shared.delegate as! AppDelegate 14 | private var sensor: SensorArray! 15 | 16 | @IBOutlet weak var pickerVenue: UIPickerView! 17 | @IBOutlet weak var buttonStart: UIButton! 18 | 19 | var venues : [UniqueVenue] = [] 20 | 21 | var venueSelected : UniqueVenue? = nil 22 | 23 | override func viewDidLoad() { 24 | // Initialise list of venues - just codes for now 25 | venues.append(UniqueVenue(country: 826, state: 4, venue: UInt32(12345), name: "Joe's Pizza")) 26 | venues.append(UniqueVenue(country: 826, state: 3, venue: UInt32(22334), name: "Adam's Fish Shop")) 27 | venues.append(UniqueVenue(country: 832, state: 1, venue: UInt32(55566), name: "Max's Fine Dining")) 28 | venues.append(UniqueVenue(country: 826, state: 4, venue: UInt32(123123), name: "Erin's Stakehouse")) 29 | 30 | pickerVenue.delegate = self 31 | pickerVenue.dataSource = self 32 | 33 | super.viewDidLoad() 34 | } 35 | 36 | @IBAction func beginBeaconing(_ sender: UIButton) { 37 | guard let venueSelected = venueSelected else { 38 | return 39 | } 40 | logger.debug("beginBeaconing for: \(venueSelected.getName())") 41 | 42 | // Now enable phone mode - initialises SensorArray 43 | let ext = ConcreteExtendedDataV1() 44 | ext.addSection(code: ExtendedDataSegmentCodesV1.TextPremises.rawValue , value: venueSelected.getName()) 45 | let pds = ConcreteBeaconPayloadDataSupplierV1(countryCode: venueSelected.getCountry(), stateCode: venueSelected.getState(), code: venueSelected.getCode(), extendedData: ext) 46 | appDelegate.startBeacon(pds) 47 | 48 | sensor = appDelegate.sensor 49 | } 50 | 51 | // MARK: UIPickerViewDataSource 52 | func numberOfComponents(in pickerView: UIPickerView) -> Int { 53 | return 1 54 | } 55 | 56 | func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { 57 | return venues.count 58 | } 59 | 60 | func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { 61 | return venues[row].getName() 62 | } 63 | 64 | func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { 65 | venueSelected = venues[row] 66 | logger.debug("Selected: \(venueSelected!.getName())") 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Herald-for-iOS/TargetDetailsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TargetDetailsViewController.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import UIKit 9 | import Herald 10 | 11 | class TargetDetailsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 12 | 13 | private let logger = Log(subsystem: "Herald", category: "ViewController") 14 | private let appDelegate = UIApplication.shared.delegate as! AppDelegate 15 | 16 | @IBOutlet weak var lblId: UILabel! 17 | @IBOutlet weak var lblPayload: UILabel! 18 | @IBOutlet weak var lblType: UILabel! 19 | @IBOutlet weak var lblVersion: UILabel! 20 | @IBOutlet weak var lblCountry: UILabel! 21 | @IBOutlet weak var lblState: UILabel! 22 | @IBOutlet weak var lblIdentifier: UILabel! 23 | 24 | @IBOutlet weak var tableExtendedData: UITableView! 25 | 26 | var target: TargetIdentifier? = nil 27 | var payloadData: PayloadData? = nil 28 | var extendedData: ExtendedData? = nil 29 | 30 | // MARK:- UIViewController 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | 35 | tableExtendedData.dataSource = self 36 | tableExtendedData.delegate = self 37 | } 38 | 39 | public func display(_ target: TargetIdentifier, payload: PayloadData) { 40 | self.target = target 41 | self.payloadData = payload 42 | 43 | lblId.text = target.description 44 | if (target.description.count > 17) { 45 | lblId.text = target.description.prefix(17) + "..." 46 | } 47 | 48 | lblPayload.text = payload.hexEncodedString 49 | // TODO trim the above text if very long 50 | 51 | // vary display based on data in Payload 52 | do { 53 | let beacon = try VenueEncounter( 54 | Proximity(unit: .RSSI, value: 0), 55 | payload 56 | ) 57 | let uv = beacon!.getVenue()! 58 | lblType.text = "Venue Beacon" 59 | lblVersion.text = "TODO" 60 | lblCountry.text = "\(uv.getCountry())" 61 | lblState.text = "\(uv.getState())" 62 | lblIdentifier.text = "\(uv.getCode())" 63 | } catch { 64 | // try next format 65 | // TODO Simple and Secured and Custom - for now, default to default 66 | } 67 | } 68 | 69 | // MARK:- UITableViewDataSource 70 | 71 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 72 | return 0 73 | } 74 | 75 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 76 | let cell = tableView.dequeueReusableCell(withIdentifier: "targetIdentifier", for: indexPath) 77 | 78 | // TODO initialise content 79 | 80 | return cell 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Herald-for-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-Spotlight-40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon-60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-Small@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-Small@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-Spotlight-40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-Spotlight-40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-Spotlight-40@3x-1.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-Spotlight-41.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-Small.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-Small@2x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-Spotlight-42.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-Spotlight-40@2x-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-iPadPro@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "virus_icon.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/ContactLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactLog.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// CSV contact log for post event analysis and visualisation 11 | public class ContactLog: SensorDelegateLogger { 12 | private let payloadDataFormatter: PayloadDataFormatter 13 | 14 | public init(filename: String, payloadDataFormatter: PayloadDataFormatter = ConcretePayloadDataFormatter()) { 15 | self.payloadDataFormatter = payloadDataFormatter 16 | super.init(filename: filename) 17 | } 18 | 19 | private func writeHeader() { 20 | if empty() { 21 | write("time,sensor,id,detect,read,measure,share,visit,detectHerald,delete,data") 22 | } 23 | } 24 | 25 | // MARK:- SensorDelegate 26 | 27 | public override func sensor(_ sensor: SensorType, didDetect: TargetIdentifier) { 28 | writeHeader() 29 | write(timestamp() + "," + sensor.rawValue + "," + csv(didDetect) + ",1,,,,,,,") 30 | } 31 | 32 | public override func sensor(_ sensor: SensorType, available: Bool, didDeleteOrDetect: TargetIdentifier) { 33 | writeHeader() 34 | if (available) { 35 | // Guaranteed to be a Herald payload capable device 36 | write(timestamp() + "," + sensor.rawValue + "," + csv(didDeleteOrDetect) + ",,,,,,6,,") 37 | } else { 38 | // Any Bluetooth device (including Herald) that has not been seen in some time 39 | write(timestamp() + "," + sensor.rawValue + "," + csv(didDeleteOrDetect) + ",,,,,,,7,") 40 | } 41 | } 42 | 43 | public override func sensor(_ sensor: SensorType, didRead: PayloadData, fromTarget: TargetIdentifier) { 44 | writeHeader() 45 | write(timestamp() + "," + sensor.rawValue + "," + csv(fromTarget) + ",,2,,,,,," + csv(payloadDataFormatter.shortFormat(didRead))) 46 | } 47 | 48 | public override func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier) { 49 | writeHeader() 50 | write(timestamp() + "," + sensor.rawValue + "," + csv(fromTarget) + ",,,3,,,,," + csv(didMeasure.description)) 51 | } 52 | 53 | public override func sensor(_ sensor: SensorType, didShare: [PayloadData], fromTarget: TargetIdentifier) { 54 | let prefix = timestamp() + "," + sensor.rawValue + "," + csv(fromTarget) 55 | didShare.forEach() { payloadData in 56 | writeHeader() 57 | write(prefix + ",,,,4,,,," + csv(payloadDataFormatter.shortFormat(payloadData))) 58 | } 59 | } 60 | 61 | public override func sensor(_ sensor: SensorType, didVisit: Location?) { 62 | var visitString = "" 63 | if let dv = didVisit { 64 | visitString = dv.description 65 | } 66 | writeHeader() 67 | write(timestamp() + "," + sensor.rawValue + ",,,,,,5,,," + csv(visitString)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/StatisticsLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatisticsLog.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// CSV contact log for post event analysis and visualisation 11 | public class StatisticsLog: SensorDelegateLogger { 12 | private let payloadData: PayloadData 13 | private var identifierToPayload: [TargetIdentifier:String] = [:] 14 | private var payloadToTime: [String:Date] = [:] 15 | private var payloadToSample: [String:SampleStatistics] = [:] 16 | 17 | public init(filename: String, payloadData: PayloadData) { 18 | self.payloadData = payloadData 19 | super.init(filename: filename) 20 | } 21 | 22 | private func add(identifier: TargetIdentifier) { 23 | guard let payload = identifierToPayload[identifier] else { 24 | return 25 | } 26 | add(payload: payload) 27 | } 28 | 29 | private func add(payload: String) { 30 | guard let time = payloadToTime[payload], let sample = payloadToSample[payload] else { 31 | payloadToTime[payload] = Date() 32 | payloadToSample[payload] = SampleStatistics() 33 | return 34 | } 35 | let now = Date() 36 | payloadToTime[payload] = now 37 | sample.add(Double(now.timeIntervalSince(time))) 38 | write() 39 | } 40 | 41 | private func write() { 42 | var content = "payload,count,mean,sd,min,max\n" 43 | var payloadList: [String] = [] 44 | payloadToSample.keys.forEach() { payload in 45 | guard payload != payloadData.shortName else { 46 | return 47 | } 48 | payloadList.append(payload) 49 | } 50 | payloadList.sort() 51 | payloadList.forEach() { payload in 52 | guard let sample = payloadToSample[payload] else { 53 | return 54 | } 55 | guard let mean = sample.mean, let sd = sample.standardDeviation, let min = sample.min, let max = sample.max else { 56 | return 57 | } 58 | content.append("\(csv(payload)),\(sample.count),\(mean),\(sd),\(min),\(max)\n") 59 | } 60 | overwrite(content) 61 | } 62 | 63 | 64 | // MARK:- SensorDelegate 65 | 66 | public override func sensor(_ sensor: SensorType, didRead: PayloadData, fromTarget: TargetIdentifier) { 67 | identifierToPayload[fromTarget] = didRead.shortName 68 | add(identifier: fromTarget) 69 | } 70 | 71 | public override func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier) { 72 | add(identifier: fromTarget) 73 | } 74 | 75 | public override func sensor(_ sensor: SensorType, didShare: [PayloadData], fromTarget: TargetIdentifier) { 76 | didShare.forEach() { payloadData in 77 | add(payload: payloadData.shortName) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Payload/Beacon/BeaconPayloadDataSupplier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeaconPayloadDataSupplier.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import CommonCrypto 10 | import Accelerate 11 | 12 | /// Beacon payload data supplier. Payload data is 9+ bytes. 13 | public protocol BeaconPayloadDataSupplier : PayloadDataSupplier { 14 | } 15 | 16 | /// Beacon payload data supplier. 17 | public class ConcreteBeaconPayloadDataSupplierV1 : BeaconPayloadDataSupplier { 18 | private static let protocolAndVersion : UInt8 = 0x30 // V1 of Beacon protocol 19 | 20 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Payload.ConcreteBeaconPayloadDataSupplierV1") 21 | private let payloadLength: Int = 9 // default, may be more with extended data area 22 | private let fullPayload: Data // 9+ bytes 23 | 24 | public init(countryCode: UInt16, stateCode: UInt16, code: UInt32, extendedData: ExtendedData? = nil) { 25 | // Generate common header 26 | // Common header = protocolAndVersion + countryCode + stateCode 27 | var commonHeader = Data() 28 | commonHeader.append(ConcreteBeaconPayloadDataSupplierV1.protocolAndVersion) 29 | commonHeader.append(countryCode) 30 | commonHeader.append(stateCode) 31 | 32 | // Generate beacon payload 33 | // Beacon payload = commonHeader + Beacon Registration Code + Extended Data 34 | var fullPayload = Data() 35 | fullPayload.append(commonHeader) 36 | fullPayload.append(code) 37 | if let extended = extendedData { 38 | // append to payload 39 | if extended.hasData() { 40 | fullPayload.append(extended.payload()!.data) 41 | } 42 | } 43 | self.fullPayload = fullPayload 44 | } 45 | 46 | // MARK:- SimplePayloadDataSupplier 47 | 48 | public func legacyPayload(_ timestamp: PayloadTimestamp = PayloadTimestamp(), device: Device?) -> PayloadData? { 49 | return nil 50 | } 51 | 52 | public func payload(_ timestamp: PayloadTimestamp = PayloadTimestamp(), device: Device?) -> PayloadData? { 53 | let payloadData = PayloadData() 54 | payloadData.append(fullPayload) 55 | return payloadData 56 | } 57 | 58 | /// Default implementation assumes fixed length payload data with no extended data 59 | public func payload(_ data: Data) -> [PayloadData] { 60 | // Split data into payloads based on fixed length 61 | var payloads: [PayloadData] = [] 62 | var indexStart = 0, indexEnd = 9 // TODO dynamically check from payload extended data 63 | while indexEnd <= data.count { 64 | let payload = PayloadData(data.subdata(in: indexStart.. Double? { 32 | guard let modeValue = mode.reduce() else { 33 | return nil 34 | } 35 | 36 | let exponent = (modeValue - intercept) / coefficient 37 | return pow(10, exponent) 38 | } 39 | 40 | public override func reset() { 41 | mode.reset() 42 | } 43 | } 44 | 45 | 46 | 47 | public class FowlerBasicAnalyser: AnalysisProvider { 48 | private let interval: TimeInterval 49 | private let basic: FowlerBasic 50 | private var lastRan: Date = Date(timeIntervalSince1970: 0) 51 | private let valid: Filter = InRange(-99, -10) 52 | 53 | public init(interval: TimeInterval = TimeInterval(10), intercept: Double = -11, coefficient: Double = -0.4) { 54 | self.interval = interval 55 | self.basic = FowlerBasic(intercept: intercept, coefficient: coefficient) 56 | super.init(ValueType(describing: RSSI.self), ValueType(describing: Distance.self)) 57 | } 58 | 59 | public override func analyse(timeNow: Date, sampled: SampledID, input: SampleList, output: SampleList, callable: CallableForNewSample) -> Bool { 60 | // Interval guard 61 | if lastRan.secondsSinceUnixEpoch + Int64(interval) >= timeNow.secondsSinceUnixEpoch { 62 | return false 63 | } 64 | basic.reset() 65 | let values = input.filter(valid).toView() 66 | let summary = values.aggregate([Mode(), Variance()]) 67 | guard let mode = summary.get(Mode.self), let variance = summary.get(Variance.self) else { 68 | return false 69 | } 70 | let sd = sqrt(variance) 71 | guard let distance = input.filter(valid).filter(InRange(mode-2*sd, mode+2*sd)).aggregate([basic]).get(FowlerBasic.self) else { 72 | return false 73 | } 74 | guard let latestTime = values.latest() else { 75 | return false 76 | } 77 | lastRan = latestTime 78 | let newSample = Sample(taken: latestTime, value: Distance(distance)) 79 | output.push(sample: newSample) 80 | callable.newSample(sampled: sampled, item: newSample) 81 | return true 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report about a specific issue 4 | title: 'Bug Report' 5 | labels: 'verify' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **NOTE**: This issue tracker is for reporting bugs encountered with the 11 | Herald API and Herald testing applications themselves, not for issues with any downstream 12 | projects that use Herald within their apps. Please report issues with those apps to their 13 | developers and allow those developers to raise any upstream issues 14 | here themselves after they have completed their initial issue investigation. This prevents 15 | rework and delay in getting your issues resolved. Any issues 16 | raised referring to those apps here and not Herald shall be closed. Only raise the issue here if it 17 | has been reproduced with the Herald demonstration app. 18 | 19 | **Describe the bug** 20 | 21 | A clear and concise description of what the bug is. 22 | 23 | **Smartphone and App information (REQUIRED):** 24 | 25 | - Device: [e.g. Samsung S20] 26 | - Device exact model (if known): [e.g. SM-G781B] 27 | - OS exact version: [e.g. iOS8.1] 28 | - Herald demonstration app version: [e.g. Release ID (v1.2.0-beta3), latest develop branch, or commit ID] 29 | - Have you reproduced this issue on the latest 'develop' Herald branch?: [Yes, No] 30 | 31 | Without the above information the Herald team will be unable to reproduce 32 | the issue, and thus also unable to fix and confirm the fix for the issue. 33 | 34 | If the above information is not provided then the issue will be marked 35 | as 'cannot reproduce'. 36 | 37 | **Severity (Project team may edit this section after reporting)** 38 | 39 | Please provide the following metrics (optional, can be filled in by project team if left blank):- 40 | 41 | - Likely How Widespread: [LOW = Less than 5% of installs of this (iOS, Android) variant, MEDIUM = 5%-50%, HIGH = More than 50%] 42 | - Reproducability: [NONE = Lab only/theoretical, or an unlikely user activity. LOW = Intermittent, unpredictable. MEDIUM = Occurs often, but unpredictable. HIGH = Easy to reproduce] 43 | - Impact: [NONE/LOW = Annoying, but functional. MEDIUM = Impacts function of the app/device, but there's a workaround. HIGH = Stops app/device functioning.] 44 | 45 | **To Reproduce** 46 | 47 | Steps to reproduce the behavior: 48 | 49 | 1. Go to '...' 50 | 2. Click on '....' 51 | 3. Scroll down to '....' 52 | 4. See error 53 | 54 | **Expected behavior** 55 | 56 | A clear and concise description of what you expected to happen. 57 | 58 | **Screenshots** 59 | 60 | If applicable, add screenshots to help explain your problem. 61 | 62 | **DEVELOPERS ONLY: Herald settings in use** 63 | 64 | If you are developing an app based on Herald, please indicate the settings you are using 65 | with Herald. In particular:- 66 | 67 | - Anything non-default from BLESensorConfiguration: [E.g. payload sharing frequency, and value] 68 | - The Payload provided used: [e.g. Secured Payload] 69 | - The Randomness provider used: [e.g. Secure Random] 70 | - Other (please indicate) 71 | 72 | **Additional context** 73 | 74 | Add any other context about the problem here. 75 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/DetectionLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetectionLog.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | /// CSV contact log for post event analysis and visualisation 12 | public class DetectionLog: SensorDelegateLogger { 13 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Data.DetectionLog") 14 | private let payloadData: PayloadData 15 | private let deviceName = UIDevice.current.name 16 | private let deviceOS = UIDevice.current.systemVersion 17 | private var payloads: Set = [] 18 | private let queue = DispatchQueue(label: "Sensor.Data.DetectionLog.Queue") 19 | private let payloadDataFormatter: PayloadDataFormatter 20 | 21 | public init(filename: String, payloadData: PayloadData, payloadDataFormatter: PayloadDataFormatter = ConcretePayloadDataFormatter()) { 22 | self.payloadData = payloadData 23 | self.payloadDataFormatter = payloadDataFormatter 24 | super.init(filename: filename) 25 | write() 26 | } 27 | 28 | private func write() { 29 | var content = "\(csv(deviceName)),iOS,\(csv(deviceOS)),\(csv(payloadDataFormatter.shortFormat(payloadData)))" 30 | var payloadList: [String] = [] 31 | payloads.forEach() { payload in 32 | guard payload != payloadDataFormatter.shortFormat(payloadData) else { 33 | return 34 | } 35 | payloadList.append(payload) 36 | } 37 | payloadList.sort() 38 | payloadList.forEach() { payload in 39 | content.append(",") 40 | content.append(csv(payload)) 41 | } 42 | logger.debug("write (content=\(content))") 43 | content.append("\n") 44 | overwrite(content) 45 | } 46 | 47 | // MARK:- SensorDelegate 48 | 49 | public override func sensor(_ sensor: SensorType, didUpdateState: SensorState) { 50 | queue.async { 51 | self.logger.debug("didUpdateState (state=\(didUpdateState)") 52 | self.write() 53 | } 54 | } 55 | 56 | public override func sensor(_ sensor: SensorType, didRead: PayloadData, fromTarget: TargetIdentifier) { 57 | queue.async { 58 | if self.payloads.insert(self.payloadDataFormatter.shortFormat(didRead)).inserted { 59 | self.logger.debug("didRead (payload=\(self.payloadDataFormatter.shortFormat(didRead)))") 60 | self.write() 61 | } 62 | } 63 | } 64 | 65 | public override func sensor(_ sensor: SensorType, didShare: [PayloadData], fromTarget: TargetIdentifier) { 66 | didShare.forEach() { payloadData in 67 | queue.async { 68 | if self.payloads.insert(self.payloadDataFormatter.shortFormat(payloadData)).inserted { 69 | self.logger.debug("didShare (payload=\(self.payloadDataFormatter.shortFormat(payloadData)))") 70 | self.write() 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Payload/Simple/BloomFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BloomFilter.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Bloom filter for probabalistic matching of large data sets. 11 | /// False positive matches are possible, but false negatives are not. 12 | /// In other words, a query returns either "possibly in set" or "definitely not in set". 13 | class BloomFilter { 14 | private let bits: UInt64 15 | private var filter: [UInt8] 16 | 17 | init(_ bytes: Int) { 18 | bits = UInt64(bytes * 8) 19 | filter = Array(repeating: UInt8(0), count: bytes) 20 | } 21 | 22 | private func setBit(_ index: UInt64, _ value: Bool) { 23 | let bit = index.remainderReportingOverflow(dividingBy: bits).partialValue 24 | let byteUInt = bit.dividedReportingOverflow(by: 8).partialValue 25 | let bitInByte = bit - (byteUInt * 8) 26 | let byte = Int(byteUInt) 27 | switch bitInByte { 28 | case 0: 29 | filter[byte] = (value ? (filter[byte] | 0b10000000) : (filter[byte] & 0b01111111)) 30 | case 1: 31 | filter[byte] = (value ? (filter[byte] | 0b01000000) : (filter[byte] & 0b10111111)) 32 | case 2: 33 | filter[byte] = (value ? (filter[byte] | 0b00100000) : (filter[byte] & 0b11011111)) 34 | case 3: 35 | filter[byte] = (value ? (filter[byte] | 0b00010000) : (filter[byte] & 0b11101111)) 36 | case 4: 37 | filter[byte] = (value ? (filter[byte] | 0b00001000) : (filter[byte] & 0b11110111)) 38 | case 5: 39 | filter[byte] = (value ? (filter[byte] | 0b00000100) : (filter[byte] & 0b11111011)) 40 | case 6: 41 | filter[byte] = (value ? (filter[byte] | 0b00000010) : (filter[byte] & 0b11111101)) 42 | default: 43 | filter[byte] = (value ? (filter[byte] | 0b00000001) : (filter[byte] & 0b11111110)) 44 | } 45 | } 46 | 47 | private func getBit(_ index: UInt64) -> Bool { 48 | let bit = index.remainderReportingOverflow(dividingBy: bits).partialValue 49 | let byteUInt = bit.dividedReportingOverflow(by: 8).partialValue 50 | let bitInByte = bit - (byteUInt * 8) 51 | let byte = Int(byteUInt) 52 | switch bitInByte { 53 | case 0: 54 | return (filter[byte] & 0b10000000) != 0 55 | case 1: 56 | return (filter[byte] & 0b01000000) != 0 57 | case 2: 58 | return (filter[byte] & 0b00100000) != 0 59 | case 3: 60 | return (filter[byte] & 0b00010000) != 0 61 | case 4: 62 | return (filter[byte] & 0b00001000) != 0 63 | case 5: 64 | return (filter[byte] & 0b00000100) != 0 65 | case 6: 66 | return (filter[byte] & 0b00000010) != 0 67 | default: 68 | return (filter[byte] & 0b00000001) != 0 69 | } 70 | } 71 | 72 | func add(_ data: Data) { 73 | setBit(digest64(data), true) 74 | setBit(digest64(Data(data.reversed())), true) 75 | } 76 | 77 | func contains(_ data: Data) -> Bool { 78 | return getBit(digest64(data)) && getBit(digest64(Data(data.reversed()))) 79 | } 80 | 81 | func digest64(_ data: Data) -> UInt64 { 82 | return F.h(data).uint64(0)! 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Motion/InertiaSensor.swift: -------------------------------------------------------------------------------- 1 | //// 2 | // InertiaSensor.swift 3 | // 4 | // Copyright 2021-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import CoreMotion 10 | 11 | /** 12 | Inertia sensor for collecting movement data from accelerometer. 13 | */ 14 | protocol InertiaSensor : Sensor { 15 | } 16 | 17 | /** 18 | Inertia sensor based on CoreMotion 19 | Requires : Info.plist : Required Device Capabilities : Accelerometer 20 | */ 21 | class ConcreteInertiaSensor : NSObject, InertiaSensor { 22 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Motion.ConcreteInertiaSensor") 23 | private let operationQueue = OperationQueue() 24 | private let delegateQueue = DispatchQueue(label: "Sensor.Motion.ConcreteInertiaSensor.DelegateQueue") 25 | private var delegates: [SensorDelegate] = [] 26 | private let updateInterval: TimeInterval 27 | private let motionManager = CMMotionManager() 28 | 29 | /// Create inertia sensor with given update interval 30 | init(updateInterval: TimeInterval = TimeInterval(0.25)) { 31 | self.updateInterval = updateInterval 32 | super.init() 33 | } 34 | 35 | func add(delegate: SensorDelegate) { 36 | delegates.append(delegate) 37 | } 38 | 39 | func start() { 40 | guard motionManager.isAccelerometerAvailable else { 41 | logger.fault("start, accelerometer is not available") 42 | return 43 | } 44 | logger.debug("start") 45 | motionManager.accelerometerUpdateInterval = updateInterval 46 | motionManager.stopAccelerometerUpdates() 47 | motionManager.startAccelerometerUpdates(to: operationQueue, withHandler: handleAccelerometerUpdates) 48 | } 49 | 50 | func stop() { 51 | logger.debug("stop") 52 | motionManager.stopAccelerometerUpdates() 53 | } 54 | 55 | public func coordinationProvider() -> CoordinationProvider? { 56 | // Class does not have a coordination provider 57 | return nil 58 | } 59 | 60 | private func handleAccelerometerUpdates(data: CMAccelerometerData?, error: Error?) { 61 | guard error == nil else { 62 | return 63 | } 64 | guard let data = data else { 65 | return 66 | } 67 | let timestamp = Date() 68 | // The values reported by the accelerometers are measured in increments 69 | // of the gravitational acceleration, with the value 1.0 representing an 70 | // acceleration of 9.8 meters per second (per second) in the given direction. 71 | // The actual values for each axis is opposite on Android and iOS, i.e. 72 | // positive value on iOS = negative value on Android. The callback shall 73 | // standardise on Android notation, where y = 9.8 means the phone is being 74 | // held vertically with the top edge towards the sky. 75 | let x = -data.acceleration.x * 9.8 76 | let y = -data.acceleration.y * 9.8 77 | let z = -data.acceleration.z * 9.8 78 | let inertiaLocationReference = InertiaLocationReference(x: x, y: y, z: z) 79 | let location = Location(value: inertiaLocationReference, time: (start: timestamp, end: timestamp)) 80 | self.delegateQueue.async { 81 | self.delegates.forEach { $0.sensor(.ACCELEROMETER, didVisit: location) } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/SensorDelegateLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SensorDelegateLogger.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Default sensor delegate with convenient functions for writing data to log file. 11 | public class SensorDelegateLogger: SensorDelegate, Resettable { 12 | private let textFile: TextFile? 13 | internal let dateFormatter = DateFormatter() 14 | 15 | public init() { 16 | textFile = nil 17 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 18 | } 19 | 20 | public init(filename: String) { 21 | textFile = TextFile(filename: filename) 22 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 23 | } 24 | 25 | public func reset() { 26 | guard let textFile = textFile else { 27 | return 28 | } 29 | textFile.reset() 30 | } 31 | 32 | /// Get current time as formatted timestamp "yyyy-MM-dd HH:mm:ss" 33 | func timestamp() -> String { 34 | let timestamp = dateFormatter.string(from: Date()) 35 | return TextFile.csv(timestamp) 36 | } 37 | 38 | /// Wrap value as CSV format value. 39 | func csv(_ value: String) -> String { 40 | return TextFile.csv(value) 41 | } 42 | 43 | /// Write line. This function will add newline character to end of line. 44 | func write(_ line: String) { 45 | guard let textFile = textFile else { 46 | return 47 | } 48 | textFile.write(line) 49 | } 50 | 51 | /// Overwrite file content. 52 | func overwrite(_ content: String) { 53 | guard let textFile = textFile else { 54 | return 55 | } 56 | textFile.overwrite(content) 57 | } 58 | 59 | /// Test if the file is empty. 60 | func empty() -> Bool { 61 | guard let textFile = textFile else { 62 | return false 63 | } 64 | return textFile.empty() 65 | } 66 | 67 | /// Return contents of file. 68 | func contentsOf() -> String { 69 | guard let textFile = textFile else { 70 | return "" 71 | } 72 | return textFile.contentsOf() 73 | } 74 | 75 | // MARK: - SensorDelegate 76 | 77 | public func sensor(_ sensor: SensorType, didVisit: Location?) { 78 | } 79 | 80 | public func sensor(_ sensor: SensorType, didDetect: TargetIdentifier) { 81 | } 82 | 83 | public func sensor(_ sensor: SensorType, available: Bool, didDeleteOrDetect: TargetIdentifier) { 84 | } 85 | 86 | public func sensor(_ sensor: SensorType, didUpdateState: SensorState) { 87 | } 88 | 89 | public func sensor(_ sensor: SensorType, didReceive: Data, fromTarget: TargetIdentifier) { 90 | } 91 | 92 | public func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier) { 93 | } 94 | 95 | public func sensor(_ sensor: SensorType, didRead: PayloadData, fromTarget: TargetIdentifier) { 96 | } 97 | 98 | public func sensor(_ sensor: SensorType, didShare: [PayloadData], fromTarget: TargetIdentifier) { 99 | } 100 | 101 | public func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier, withPayload: PayloadData) { 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/swift,xcode 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode 5 | 6 | ### Swift ### 7 | # Xcode 8 | # 9 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 10 | 11 | ## User settings 12 | xcuserdata/ 13 | 14 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 15 | *.xcscmblueprint 16 | *.xccheckout 17 | 18 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 19 | build/ 20 | DerivedData/ 21 | *.moved-aside 22 | *.pbxuser 23 | !default.pbxuser 24 | *.mode1v3 25 | !default.mode1v3 26 | *.mode2v3 27 | !default.mode2v3 28 | *.perspectivev3 29 | !default.perspectivev3 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | 34 | ## App packaging 35 | *.ipa 36 | *.dSYM.zip 37 | *.dSYM 38 | 39 | ## Playgrounds 40 | timeline.xctimeline 41 | playground.xcworkspace 42 | 43 | # Swift Package Manager 44 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 45 | # Packages/ 46 | # Package.pins 47 | # Package.resolved 48 | # *.xcodeproj 49 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 50 | # hence it is not needed unless you have added a package configuration file to your project 51 | # .swiftpm 52 | 53 | .build/ 54 | 55 | # CocoaPods 56 | # We recommend against adding the Pods directory to your .gitignore. However 57 | # you should judge for yourself, the pros and cons are mentioned at: 58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 59 | Pods/ 60 | # Add this line if you want to avoid checking in source code from the Xcode workspace 61 | # *.xcworkspace 62 | 63 | # Carthage 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Add this lines if you are using Accio dependency management (Deprecated since Xcode 12) 70 | # Dependencies/ 71 | # .accio/ 72 | 73 | # fastlane 74 | # It is recommended to not store the screenshots in the git repo. 75 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 76 | # For more information about the recommended setup visit: 77 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 78 | 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots/**/*.png 82 | fastlane/test_output 83 | 84 | # Code Injection 85 | # After new code Injection tools there's a generated folder /iOSInjectionProject 86 | # https://github.com/johnno1962/injectionforxcode 87 | 88 | iOSInjectionProject/ 89 | 90 | ### Xcode ### 91 | # Xcode 92 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 93 | 94 | 95 | 96 | 97 | ## Gcc Patch 98 | /*.gcno 99 | 100 | ### Xcode Patch ### 101 | *.xcodeproj/* 102 | !*.xcodeproj/project.pbxproj 103 | !*.xcodeproj/xcshareddata/ 104 | !*.xcworkspace/contents.xcworkspacedata 105 | **/xcshareddata/WorkspaceSettings.xcsettings 106 | 107 | # End of https://www.toptal.com/developers/gitignore/api/swift,xcode 108 | 109 | 110 | git-template 111 | Herald/docs 112 | Herald/build 113 | sync.sh 114 | **/.build 115 | /Packages 116 | 117 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Datatype/RingBuffer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RingBuffer.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class RingBuffer { 11 | private var data: [T?] 12 | private var oldestPosition, newestPosition: Int 13 | public var description: String { get { 14 | guard size() > 0 else { 15 | return "[]" 16 | } 17 | var s: String = "[" 18 | for i in 0...size()-1 { 19 | guard let item = get(i) else { 20 | continue 21 | } 22 | if s.count > 1 { 23 | s += " ," 24 | } 25 | s += String(describing: item) 26 | } 27 | s += "]" 28 | return s 29 | }} 30 | 31 | public init(_ size: Int) { 32 | self.data = [T?](repeating: nil, count: size) 33 | self.oldestPosition = size 34 | self.newestPosition = size 35 | } 36 | 37 | public func push(_ item: T) { 38 | incrementNewest() 39 | data[newestPosition] = item 40 | } 41 | 42 | public func size() -> Int { 43 | if newestPosition == data.count { 44 | return 0 45 | } 46 | if newestPosition >= oldestPosition { 47 | // not overlapping the end 48 | return newestPosition - oldestPosition + 1 49 | } 50 | // we've overlapped 51 | return (1 + newestPosition) + (data.count - oldestPosition) 52 | } 53 | 54 | public func get(_ index: Int) -> T? { 55 | if newestPosition >= oldestPosition { 56 | guard let item = data[index + oldestPosition] else { 57 | return nil 58 | } 59 | return item 60 | } 61 | if index + oldestPosition >= data.count { 62 | // TODO handle the situation where this pos > newestPosition (i.e. gap in the middle) 63 | guard let item = data[index + oldestPosition - data.count] else { 64 | return nil 65 | } 66 | return item 67 | } 68 | guard let item = data[index + oldestPosition] else { 69 | return nil 70 | } 71 | return item 72 | } 73 | 74 | public func clear() { 75 | oldestPosition = data.count 76 | newestPosition = data.count 77 | } 78 | 79 | public func latest() -> T? { 80 | guard newestPosition != data.count, let item = data[newestPosition] else { 81 | return nil 82 | } 83 | return item 84 | } 85 | 86 | private func incrementNewest() { 87 | if newestPosition == data.count { 88 | newestPosition = 0 89 | oldestPosition = 0 90 | } else { 91 | if newestPosition == (oldestPosition - 1) { 92 | oldestPosition += 1 93 | if oldestPosition == data.count { 94 | oldestPosition = 0 95 | } 96 | } 97 | newestPosition += 1 98 | } 99 | if newestPosition == data.count { 100 | // just gone past the end of the container 101 | newestPosition = 0; 102 | if oldestPosition == 0 { 103 | // erases oldest if not already removed 104 | oldestPosition += 1 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Herald-for-iOS.xcodeproj/xcshareddata/xcschemes/Herald-for-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 50 | 52 | 58 | 59 | 60 | 61 | 67 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/SensorLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import os 11 | 12 | public protocol SensorLogger { 13 | init(subsystem: String, category: String) 14 | 15 | func log(_ level: SensorLoggerLevel, _ message: String) 16 | 17 | func debug(_ message: String) 18 | 19 | func info(_ message: String) 20 | 21 | func fault(_ message: String) 22 | } 23 | 24 | public enum SensorLoggerLevel: String { 25 | case off, debug, info, fault 26 | } 27 | 28 | public class ConcreteSensorLogger: NSObject, SensorLogger, Resettable { 29 | private let subsystem: String 30 | private let category: String 31 | private let dateFormatter = DateFormatter() 32 | private let log: OSLog? 33 | private static let logFile = TextFile(filename: "log.txt") 34 | 35 | public required init(subsystem: String, category: String) { 36 | self.subsystem = subsystem 37 | self.category = category 38 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 39 | if #available(iOS 10.0, *) { 40 | log = OSLog(subsystem: subsystem, category: category) 41 | } else { 42 | log = nil 43 | } 44 | } 45 | 46 | private func suppress(_ level: SensorLoggerLevel) -> Bool { 47 | if (BLESensorConfiguration.logLevel == .off) { 48 | return true 49 | } 50 | switch level { 51 | case .debug: 52 | return (BLESensorConfiguration.logLevel == .info || BLESensorConfiguration.logLevel == .fault) 53 | case .info: 54 | return (BLESensorConfiguration.logLevel == .fault) 55 | default: 56 | return false 57 | } 58 | } 59 | 60 | public func log(_ level: SensorLoggerLevel, _ message: String) { 61 | guard !suppress(level) else { 62 | return 63 | } 64 | // Write to unified os log if available, else print to console 65 | let timestamp = dateFormatter.string(from: Date()) 66 | let csvMessage = message.replacingOccurrences(of: "\"", with: "'") 67 | let quotedMessage = (message.contains(",") ? "\"" + csvMessage + "\"" : csvMessage) 68 | let entry = timestamp + "," + level.rawValue + "," + subsystem + "," + category + "," + quotedMessage 69 | ConcreteSensorLogger.logFile.write(entry) 70 | guard let log = log else { 71 | print(entry) 72 | return 73 | } 74 | if #available(iOS 10.0, *) { 75 | switch (level) { 76 | case .debug: 77 | os_log("%s", log: log, type: .debug, message) 78 | case .info: 79 | os_log("%s", log: log, type: .info, message) 80 | case .fault: 81 | os_log("%s", log: log, type: .fault, message) 82 | default: 83 | return 84 | } 85 | return 86 | } 87 | } 88 | 89 | public func debug(_ message: String) { 90 | log(.debug, message) 91 | } 92 | 93 | public func info(_ message: String) { 94 | log(.info, message) 95 | } 96 | 97 | public func fault(_ message: String) { 98 | log(.fault, message) 99 | } 100 | 101 | // MARK: - Resettable 102 | 103 | public func reset() { 104 | ConcreteSensorLogger.logFile.reset() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Algorithms/Risk/RiskAggregationBasic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RiskAggregationBasic.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// A Basic sample but non scientific risk aggregation model. 11 | /// Similar in function to the Oxford Risk Model, but without its calibration values and scaling. 12 | /// NOT FOR PRODUCTION EPIDEMIOLOGICAL USE - SAMPLE ONLY!!! 13 | public class RiskAggregationBasic: Aggregate { 14 | public override var runs: Int { get { 1 }} 15 | private var run: Int = 1 16 | private var timeScale: Double 17 | private var distanceScale: Double 18 | private var minimumDistanceClamp: Double 19 | private var minimumRiskScoreAtClamp: Double 20 | private var logScale: Double 21 | private var nMinusOne: Double // distance of n-1 22 | private var n: Double // distance of n 23 | private var timeMinusOne: Int64 // time of n-1 24 | private var time: Int64 // time of n 25 | private var riskScore: Double 26 | 27 | public init(timeScale: Double, distanceScale: Double, minimumDistanceClamp: Double, minimumRiskScoreAtClamp: Double, logScale: Double = 3.3598856662) { 28 | self.timeScale = timeScale 29 | self.distanceScale = distanceScale 30 | self.minimumDistanceClamp = minimumDistanceClamp 31 | self.minimumRiskScoreAtClamp = minimumRiskScoreAtClamp 32 | self.logScale = logScale 33 | self.nMinusOne = -1 34 | self.n = -1 35 | self.timeMinusOne = 0 36 | self.time = 0 37 | self.riskScore = 0 38 | } 39 | 40 | public override func beginRun(thisRun: Int) { 41 | run = thisRun 42 | if (1 == run) { 43 | // clear run temporaries 44 | nMinusOne = -1.0 45 | n = -1.0 46 | timeMinusOne = 0 47 | time = 0 48 | } 49 | } 50 | 51 | public override func map(value: Sample) { 52 | nMinusOne = n 53 | timeMinusOne = time 54 | n = value.value.value 55 | time = value.taken.secondsSinceUnixEpoch 56 | } 57 | 58 | public override func reduce() -> Double? { 59 | if -1.0 != nMinusOne { 60 | // we have two values with which to calculate 61 | // using nMinusOne and n, and calculate interim risk score addition 62 | let dist = distanceScale * n 63 | let t = timeScale * Double(time - timeMinusOne) // seconds 64 | 65 | var riskSlice = minimumRiskScoreAtClamp // assume < clamp distance 66 | if dist > minimumDistanceClamp { 67 | // otherwise, do the inverse log of distance to get the risk score 68 | 69 | // don't forget to clamp at risk score 70 | riskSlice = minimumRiskScoreAtClamp - (logScale * log10(dist)) 71 | if riskSlice > minimumRiskScoreAtClamp { 72 | // possible as the passed in logScale could be a negative 73 | riskSlice = minimumRiskScoreAtClamp 74 | } 75 | if riskSlice < 0.0 { 76 | riskSlice = 0.0 // cannot have a negative slice 77 | } 78 | } 79 | riskSlice *= t 80 | 81 | // add it to the risk score 82 | riskScore += riskSlice 83 | } 84 | 85 | // return current full risk score 86 | return riskScore 87 | } 88 | 89 | public override func reset() { 90 | run = 1 91 | riskScore = 0.0 92 | nMinusOne = -1.0 93 | n = -1.0 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Herald/HeraldTests/KeyExchangeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyExchangeTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class KeyExchangeTests: XCTestCase { 12 | 13 | public func testKeyPair() { 14 | let keyExchange = DiffieHellmanMerkle(DiffieHellmanParameters.random128) 15 | let (privateKey, publicKey) = keyExchange.keyPair() 16 | // Note: Private/Public key data always starts with 08000000 here because the first 4 bytes is the key length 17 | print("privateKey=\(privateKey.hexEncodedString),length=\(privateKey.count)") 18 | print("publicKey=\(publicKey.hexEncodedString),length=\(publicKey.count)") 19 | // Count = UInt32 (4 bytes) + 128-bit key (16 bytes) = 20 bytes 20 | XCTAssertEqual(privateKey.count, 20) 21 | XCTAssertEqual(publicKey.count, 20) 22 | } 23 | 24 | public func testKeyExchange() { 25 | let keyExchange = DiffieHellmanMerkle(DiffieHellmanParameters.random128) 26 | 27 | let (alicePrivateKey, alicePublicKey) = keyExchange.keyPair() 28 | print("alice private key bytes: \(alicePrivateKey.count)") 29 | print("alice private key = \(alicePrivateKey.hexEncodedString)") 30 | print("alice public key bytes: \(alicePublicKey.count)") 31 | print("alice public key = \(alicePublicKey.hexEncodedString)") 32 | 33 | let (bobPrivateKey, bobPublicKey) = keyExchange.keyPair() 34 | print("bob private key bytes: \(bobPrivateKey.count)") 35 | print("bob private key = \(bobPrivateKey.hexEncodedString)") 36 | print("bob public key bytes: \(bobPublicKey.count)") 37 | print("bob public key = \(bobPublicKey.hexEncodedString)") 38 | 39 | let aliceSharedKey = keyExchange.sharedKey(own: alicePrivateKey, peer: bobPublicKey)! 40 | print("alice shared key bytes: \(aliceSharedKey.count)") 41 | print("alice shared key = \(aliceSharedKey.hexEncodedString)") 42 | let bobSharedKey = keyExchange.sharedKey(own: bobPrivateKey, peer: alicePublicKey)! 43 | print("bob shared key bytes: \(bobSharedKey.count)") 44 | print("bob shared key = \(bobSharedKey.hexEncodedString)") 45 | 46 | XCTAssertEqual(aliceSharedKey, bobSharedKey) 47 | } 48 | 49 | public func testCrossPlatform() throws { 50 | let keyExchange = DiffieHellmanMerkle(DiffieHellmanParameters.random128) 51 | let alicePrivateKey = KeyExchangePrivateKey(hexEncodedString: "08000000D467F3ABF521BABDF238F07602BC6F28")! 52 | let alicePublicKey = KeyExchangePublicKey(hexEncodedString: "080000003BD578EC0E412261EE10F80E0C055896")! 53 | let bobPrivateKey = KeyExchangePrivateKey(hexEncodedString: "0800000055981B228A3030AFCB2E6CF5B0A7822F")! 54 | let bobPublicKey = KeyExchangePublicKey(hexEncodedString: "08000000D644A2045C53D6CCF6B5180756C85E16")! 55 | let aliceSharedKey = keyExchange.sharedKey(own: alicePrivateKey, peer: bobPublicKey)! 56 | let bobSharedKey = keyExchange.sharedKey(own: bobPrivateKey, peer: alicePublicKey)! 57 | XCTAssertEqual(aliceSharedKey, bobSharedKey) 58 | var csv = "key,value\n" 59 | csv.append("alicePrivate,\(alicePrivateKey.hexEncodedString)\n") 60 | csv.append("alicePublic,\(alicePublicKey.hexEncodedString)\n") 61 | csv.append("bobPrivate,\(bobPrivateKey.hexEncodedString)\n") 62 | csv.append("bobPublic,\(bobPublicKey.hexEncodedString)\n") 63 | csv.append("aliceShared,\(aliceSharedKey.hexEncodedString)\n") 64 | csv.append("bobShared,\(bobSharedKey.hexEncodedString)\n") 65 | let attachment = XCTAttachment(string: csv) 66 | attachment.lifetime = .keepAlways 67 | attachment.name = "keyExchange.csv" 68 | add(attachment) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Herald/HeraldTests/SocialDistanceTests.swift: -------------------------------------------------------------------------------- 1 | //// 2 | // SocialDistanceTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class SocialDistanceTests: XCTestCase { 12 | 13 | func testScoreByProximity() { 14 | let socialDistance = SocialDistance() 15 | XCTAssertEqual(socialDistance.scoreByProximity(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 0) 16 | 17 | // Close enough to count 18 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: 0), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:00:00+0000")!)!) 19 | XCTAssertEqual(socialDistance.scoreByProximity(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 1/60) 20 | 21 | // Too far away to count 22 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -66), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 23 | XCTAssertEqual(socialDistance.scoreByProximity(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 1/60) 24 | 25 | // Close enough to count but 0% contribution 26 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -65), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 27 | XCTAssertEqual(socialDistance.scoreByProximity(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 1/60) 28 | 29 | // Close enough to count at 100% contribution 30 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: 0), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 31 | XCTAssertEqual(socialDistance.scoreByProximity(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 2/60) 32 | 33 | } 34 | 35 | func testScoreByTarget() { 36 | let socialDistance = SocialDistance() 37 | // XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), 0) 38 | 39 | // Close enough to count 40 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: 0), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:00:00+0000")!)!) 41 | XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), (1/6)/60) 42 | 43 | // Too far away to count 44 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -66), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 45 | XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), (1/6)/60) 46 | 47 | // Close enough to count 48 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -65), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 49 | XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), (2/6)/60) 50 | 51 | // Close enough to count but same device 52 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -56), PayloadData(repeating: 0, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 53 | XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), (2/6)/60) 54 | 55 | // Close enough to count and new device 56 | socialDistance.append(Encounter(Proximity(unit: .RSSI, value: -56), PayloadData(repeating: 1, count: 1), timestamp: K.date("2020-09-24T00:01:00+0000")!)!) 57 | XCTAssertEqual(socialDistance.scoreByTarget(K.date("2020-09-24T00:00:00+0000")!, K.date("2020-09-24T01:00:00+0000")!), (3/6)/60) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Security report 3 | about: Report a specific security concern 4 | title: 'Security Report' 5 | labels: 'verify' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **WARNING*:: IF YOU ARE REPORTING AN ISSUE THAT COULD CAUSE 11 | IMMEDIATE SECURITY CONCERNS THAT PUT DEVICES OR PEOPLE AT RISK THEN 12 | PLEASE INSTEAD EMAIL THE TSC CHAIR:- 13 | 14 | adam@adamfowler.org 15 | 16 | For other security concerns, please continue. 17 | 18 | Note: Please report issues with upstream projects that we rely on (e.g. iOS Core Bluetooth, Android OS, etc.) via these two procedures too. 19 | 20 | **NOTE**: This issue tracker is for reporting bugs encountered with the 21 | Herald API and Herald testing applications themselves, not for issues with any downstream 22 | projects that use Herald within their apps. Please report issues with those apps to their 23 | developers and allow those developers to raise any upstream issues 24 | here themselves after they have completed their initial issue investigation. This prevents 25 | rework and delay in getting your issues resolved. Any issues 26 | raised referring to those apps here and not Herald shall be closed. Only raise the issue here if it 27 | has been reproduced with the Herald demonstration app. 28 | 29 | Example: If Herald provides 4 options for a piece of security functionality and a downstream app 30 | is using one you believe to be insecure, then please report this as a concern in their issues tracker. 31 | 32 | **Describe the security concern** 33 | 34 | A clear and concise description of what the security concern is. 35 | 36 | **Smartphone and App information (REQUIRED if you have produced an exploit):** 37 | 38 | - Device: [e.g. Samsung S20] 39 | - Device exact model (if known): [e.g. SM-G781B] 40 | - OS exact version: [e.g. iOS8.1, Android 11] 41 | - OS security patch/exact release: [Optional. E.g. 1st Oct 2020. See Settings > Device Information on your phone] 42 | - Herald demonstration app version: [e.g. Release ID (v1.2.0-beta3), latest develop branch, or commit ID] 43 | - Have you reproduced this issue on the latest 'develop' Herald branch?: [Yes, No] 44 | 45 | Without the above information the Herald team will be unable to reproduce 46 | the issue, and thus also unable to fix and confirm the fix for the issue. 47 | 48 | If the above information is not provided then the issue will be marked 49 | as 'cannot reproduce'. 50 | 51 | **Severity (Project team may edit this section after reporting)** 52 | 53 | Please provide the following metrics (optional, can be filled in by project team if left blank):- 54 | 55 | - Likely How Widespread: [LOW = Less than 5% of installs of this (iOS, Android) variant, MEDIUM = 5%-50%, HIGH = More than 50%] 56 | - Reproducability: [NONE = Lab only/theoretical, or an unlikely user activity. LOW = Intermittent, unpredictable. MEDIUM = Occurs often, but unpredictable. HIGH = Easy to reproduce] 57 | - Impact: [LOW = Annoying, but functional. MEDIUM = Impacts function of the app/device, but there's a workaround. HIGH = Stops app/device functioning.] 58 | 59 | **Describe the potential solution you'd like** 60 | 61 | A clear and concise description of what you want to happen within Herald. 62 | 63 | **Describe alternatives you've considered** 64 | 65 | A clear and concise description of any alternative solutions or features you've considered. 66 | 67 | **DEVELOPERS ONLY: Herald settings in use** 68 | 69 | If you are developing an app based on Herald, please indicate the settings you are using 70 | with Herald. In particular:- 71 | 72 | - Anything non-default from BLESensorConfiguration: [E.g. payload sharing frequency, and value] 73 | - The Payload provided used: [e.g. Secured Payload] 74 | - The Randomness provider used: [e.g. Secure Random] 75 | - Other (please indicate) 76 | 77 | **Additional context** 78 | 79 | Add any other context about the problem here. 80 | 81 | **Notification** 82 | 83 | DO NOT MODIFY THE BELOW - it will alert the maintainers once you submit your report. 84 | 85 | @theheraldproject/committers 86 | 87 | -------------------------------------------------------------------------------- /Herald/HeraldTests/TransportLayerSecurityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransportLayerSecurityTests.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class TransportLayerSecurityTests: XCTestCase { 12 | 13 | public func testTransportLayerSecuritySession() throws { 14 | let keyExchangeParameters: DiffieHellmanParameters = .random128 15 | let alice: TransportLayerSecurity = ConcreteTransportLayerSecurity(keyExchangeParameters: keyExchangeParameters) 16 | let bob: TransportLayerSecurity = ConcreteTransportLayerSecurity(keyExchangeParameters: keyExchangeParameters) 17 | for i in 0...10 { 18 | print("testTransportLayerSecurity (count=\(i))") 19 | let data = Data(repeating: UInt8(i), count: i) 20 | // Alice reads public key from Bob 21 | let bobPublicKey = bob.readPublicKey() 22 | print("testTransportLayerSecurity (count=\(i),bobPublicKeyCount=\(bobPublicKey.count))") 23 | // Alice encrypted data for Bob 24 | let aliceEncryptedData = alice.writeEncryptedData(peerPublicKey: bobPublicKey, data: data)! 25 | print("testTransportLayerSecurity (count=\(i),aliceEncryptedDataCount=\(aliceEncryptedData.count))") 26 | // Bob decrypts data from Alice 27 | let (bobSessionId, bobDecryptedData) = bob.receiveEncryptedData(aliceEncryptedData)! 28 | XCTAssertEqual(data, bobDecryptedData) 29 | // Alice reads encrypted data from Bob 30 | let bobEncryptedData = bob.readEncryptedData(sessionId: bobSessionId, data: data)! 31 | print("testTransportLayerSecurity (count=\(i),bobEncryptedDataCount=\(bobEncryptedData.count))") 32 | // Alice decrypts data from Bob 33 | let (_, aliceDecryptedData) = alice.receiveEncryptedData(bobEncryptedData)! 34 | XCTAssertEqual(data, aliceDecryptedData) 35 | } 36 | } 37 | 38 | public func testCrossPlatform() throws { 39 | let keyExchangeParameters: DiffieHellmanParameters = .random128 40 | let pseudoRandomFunction: PseudoRandomFunction = TestRandomFunction(0) 41 | let alice: TransportLayerSecurity = ConcreteTransportLayerSecurity(keyExchangeParameters: keyExchangeParameters, random: pseudoRandomFunction) 42 | let bob: TransportLayerSecurity = ConcreteTransportLayerSecurity(keyExchangeParameters: keyExchangeParameters, random: pseudoRandomFunction) 43 | var csv = "key,bobPublicKey,aliceEncryptedData,bobSessionId,bobDecryptedData,bobEncryptedData,aliceSessionId,aliceDecryptedData\n" 44 | for i in 0...10 { 45 | let data = Data(repeating: UInt8(i), count: i) 46 | // Alice reads public key from Bob 47 | let bobPublicKey = bob.readPublicKey() 48 | // Alice encrypted data for Bob 49 | let aliceEncryptedData = alice.writeEncryptedData(peerPublicKey: bobPublicKey, data: data)! 50 | // Bob decrypts data from Alice 51 | let (bobSessionId, bobDecryptedData) = bob.receiveEncryptedData(aliceEncryptedData)! 52 | XCTAssertEqual(data, bobDecryptedData) 53 | // Alice reads encrypted data from Bob 54 | let bobEncryptedData = bob.readEncryptedData(sessionId: bobSessionId, data: data)! 55 | // Alice decrypts data from Bob 56 | let (aliceSessionId, aliceDecryptedData) = alice.receiveEncryptedData(bobEncryptedData)! 57 | XCTAssertEqual(data, aliceDecryptedData) 58 | csv.append("\(i),\(bobPublicKey.hexEncodedString),\(aliceEncryptedData.hexEncodedString),\(bobSessionId),\(bobDecryptedData.hexEncodedString),\(bobEncryptedData.hexEncodedString),\(aliceSessionId),\(aliceDecryptedData.hexEncodedString)\n") 59 | } 60 | let attachment = XCTAttachment(string: csv) 61 | attachment.lifetime = .keepAlways 62 | attachment.name = "transportLayerSecurity.csv" 63 | add(attachment) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/Sampling/Filter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Filter.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class Filter { 11 | public func test(item: Sample) -> Bool { 12 | return true 13 | } 14 | } 15 | 16 | // MARK: - Filters 17 | 18 | public class NoOp: Filter { 19 | 20 | public override func test(item: Sample) -> Bool { 21 | return true 22 | } 23 | } 24 | 25 | // MARK: - Value filters 26 | 27 | public class GreaterThan: Filter { 28 | private let min: Double 29 | 30 | public init(_ min: Double) { 31 | self.min = min 32 | } 33 | 34 | public override func test(item: Sample) -> Bool { 35 | return item.value.value > min 36 | } 37 | } 38 | 39 | public class LessThan: Filter { 40 | private let max: Double 41 | 42 | public init(_ max: Double) { 43 | self.max = max 44 | } 45 | 46 | public override func test(item: Sample) -> Bool { 47 | return item.value.value < max 48 | } 49 | } 50 | 51 | public class InRange: Filter { 52 | private let min: Double 53 | private let max: Double 54 | 55 | public init(_ min: Double, _ max: Double) { 56 | self.min = min 57 | self.max = max 58 | } 59 | 60 | public override func test(item: Sample) -> Bool { 61 | return item.value.value >= min && item.value.value <= max 62 | } 63 | } 64 | 65 | // MARK: - Time filters 66 | 67 | public class Since: Filter { 68 | private let after: Date 69 | 70 | public init(_ after: Date) { 71 | self.after = after 72 | } 73 | 74 | public convenience init(_ secondsSinceUnixEpoch: Int) { 75 | self.init(Date(timeIntervalSince1970: TimeInterval(secondsSinceUnixEpoch))) 76 | } 77 | 78 | public convenience init(_ timeIntervalSince1970: TimeInterval) { 79 | self.init(Date(timeIntervalSince1970: timeIntervalSince1970)) 80 | } 81 | 82 | public convenience init(recent: TimeInterval) { 83 | self.init(Date(timeIntervalSinceNow: -recent)) 84 | } 85 | 86 | public override func test(item: Sample) -> Bool { 87 | return item.taken >= after 88 | } 89 | } 90 | 91 | public class Until: Filter { 92 | private let before: Date 93 | 94 | public init(_ before: Date) { 95 | self.before = before 96 | } 97 | 98 | public convenience init(_ secondsSinceUnixEpoch: Int) { 99 | self.init(Date(timeIntervalSince1970: TimeInterval(secondsSinceUnixEpoch))) 100 | } 101 | 102 | public convenience init(_ timeIntervalSince1970: TimeInterval) { 103 | self.init(Date(timeIntervalSince1970: timeIntervalSince1970)) 104 | } 105 | 106 | public convenience init(recent: TimeInterval) { 107 | self.init(Date(timeIntervalSinceNow: -recent)) 108 | } 109 | 110 | public override func test(item: Sample) -> Bool { 111 | return item.taken <= before 112 | } 113 | } 114 | 115 | public class InPeriod: Filter { 116 | private let after: Date 117 | private let before: Date 118 | 119 | public init(_ after: Date, _ before: Date) { 120 | self.after = after 121 | self.before = before 122 | } 123 | 124 | public convenience init(_ afterSecondsSinceUnixEpoch: Int, _ beforeSecondsSinceUnixEpoch: Int) { 125 | self.init( 126 | Date(timeIntervalSince1970: TimeInterval(afterSecondsSinceUnixEpoch)), 127 | Date(timeIntervalSince1970: TimeInterval(beforeSecondsSinceUnixEpoch))) 128 | } 129 | 130 | public convenience init(_ afterTimeIntervalSince1970: TimeInterval, _ beforeTimeIntervalSince1970: TimeInterval) { 131 | self.init( 132 | Date(timeIntervalSince1970: afterTimeIntervalSince1970), 133 | Date(timeIntervalSince1970: beforeTimeIntervalSince1970)) 134 | } 135 | 136 | public override func test(item: Sample) -> Bool { 137 | return after <= item.taken && item.taken <= before 138 | } 139 | } 140 | 141 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Analysis/SocialDistance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocialDistance.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Estimate social distance to other app users to encourage people to keep their distance from 11 | /// people. This is intended to be used to generate a daily score as indicator of behavioural change 12 | /// to improve awareness of social mixing behaviour. 13 | public class SocialDistance: Interactions { 14 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Analysis.SocialDistance") 15 | 16 | /// Calculate social distance score based on maximum RSSI per 1 minute time window over duration 17 | /// A score of 1.0 means RSSI >= measuredPower in every minute, score of 0.0 means no encounter 18 | /// or RSSI less than excludeRssiBelow in every minute. 19 | /// - measuredPower defines RSSI at 1 metre 20 | /// - excludeRssiBelow defines minimum RSSI to include in analysis 21 | public func scoreByProximity(_ start: Date, _ end: Date = Date(), measuredPower: Double = -32, excludeRssiBelow: Double = -65) -> Double { 22 | // Get encounters over time period 23 | let encounters = subdata(start: start, end: end) 24 | // Get number of minutes in time period 25 | let duration = ceil(end.timeIntervalSince(start) / 60) 26 | // Get interactions for each time windows over time period 27 | let timeWindows = reduceByTime(encounters, duration: 60) 28 | // Get sum of exposure in each time window 29 | let rssiRange = measuredPower - excludeRssiBelow 30 | var totalScore = 0.0 31 | timeWindows.forEach() { timeWindow in 32 | var maxRSSI: Double? 33 | timeWindow.context.values.forEach() { proximities in 34 | proximities.forEach() { proximity in 35 | guard proximity.unit == .RSSI, proximity.value >= excludeRssiBelow, proximity.value <= 0 else { 36 | return 37 | } 38 | maxRSSI = max(proximity.value, maxRSSI ?? proximity.value) 39 | } 40 | } 41 | guard let rssi = maxRSSI else { 42 | return 43 | } 44 | let rssiDelta = measuredPower - min(rssi, measuredPower) 45 | let rssiPercentage = 1.0 - (rssiDelta / rssiRange) 46 | totalScore = totalScore + rssiPercentage 47 | } 48 | // Score for time period is totalScore / duration 49 | let score = totalScore / duration 50 | return score 51 | } 52 | 53 | /// Calculate social distance score based on number of different devices per 1 minute time window over duration 54 | /// A score of 1.0 means 6 or more in every minute, score of 0.0 means no device in every minute. 55 | public func scoreByTarget(_ start: Date, _ end: Date = Date(), maximumDeviceCount: Int = 6, excludeRssiBelow: Double = -65) -> Double { 56 | // Get encounters over time period 57 | let encounters = subdata(start: start, end: end) 58 | // Get number of minutes in time period 59 | let duration = ceil(end.timeIntervalSince(start) / 60) 60 | // Get interactions for each time windows over time period 61 | let timeWindows = reduceByTime(encounters, duration: 60) 62 | // Get sum of exposure in each time window 63 | var totalScore = 0.0 64 | timeWindows.forEach() { timeWindow in 65 | var devices = 0 66 | timeWindow.context.values.forEach() { proximities in 67 | if proximities.filter({ $0.unit == .RSSI && $0.value >= excludeRssiBelow && $0.value <= 0 }).count > 0 { 68 | devices = devices + 1 69 | } 70 | } 71 | let devicesPercentage = (Double(min(devices, maximumDeviceCount)) / Double(maximumDeviceCount)) 72 | totalScore = totalScore + devicesPercentage 73 | } 74 | // Score for time period is totalScore / duration 75 | let score = totalScore / duration 76 | return score 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Herald-for-iOS 2 | 3 | ![Epidemiology](images/epidemiology.png) 4 | 5 | Continuous proximity detection across iOS and Android devices in background mode for contact tracing and infection control according to epidemiology requirements. 6 | 7 | All files are copyright 2020-2021 Herald Project Contributors and 8 | are provided under the Apache 2.0 license. 9 | 10 | [![License: Apache-2.0](https://img.shields.io/badge/License-Apache2.0-yellow.svg)](https://opensource.org/licenses/Apache-2.0) 11 | 12 | See LICENSE.txt and NOTICE.txt for details. 13 | 14 | ### Contents 15 | 16 | - [Introduction](#introduction) 17 | - [Key features](#key-features) 18 | - [Hardware requirements](#hardware-requirements) 19 | - [Quick start](#quick-start) 20 | - [Test results](#test-results) 21 | - [References](#references) 22 | 23 | ## Introduction 24 | 25 | ![Efficacy](images/efficacy.png) 26 | 27 | This solution will: 28 | 29 | - Operate on 98.0% of UK phones and 97.5% of phones worldwide without requiring a software update. 30 | - Detect 100% of iOS and Android devices within 8 metres for contact tracing. 31 | - Measure distance between devices at least once every 30 seconds for infection risk estimation. 32 | 33 | This is a new, original, free and open source cross-platform proximity detection solution that has been developed according to epidemiology requirements (Ferretti, et al., 2020) for controlling COVID-19. This Bluetooth Low Energy (BLE) based solution offers accurate and frequent distance measurements between phones running iOS 9.3+ and Android 5.0+, including devices that do not support BLE advertising (circa 35% in the UK). 34 | 35 | ## Key features 36 | 37 | - Works on the vast majority of phones in the UK (98.0%) and worldwide (97.5%) by minimising operating system and hardware requirements (Statcounter, 2020). 38 | - Fully operational as a background app on both iOS and Android devices for consistent and continuous use to maximise disease transmission monitoring and control across the population. 39 | - Low power usage (circa 2% per hour) to maximise population acceptance for continuous use. 40 | - Detection and identification of iOS and Android devices in both foreground and background modes is 100% to maximise contact tracing coverage. 41 | - One or more distance measurement per 30 second window for devices within epidemiologically relevant range (8 metres) for accurate infection risk estimation and case isolation; coverage is > 99.5% of 30 second windows for 2 to 3 devices, and 93% - 96% of windows for 9 to 10 devices. 42 | - RSSI measurements for distance estimation is 98.5% accurate within epidemiologically relevant range (8 metres). 43 | - Device identification payload agnostic to support both centralised, and decentralised approaches, as well as retrospective integration into existing solutions. 44 | - Transmit and receive for Herald Protocol based payloads 45 | - Transmit and receive for BlueTrace payloads 46 | - Receive for GAEN payloads 47 | - Apache-2.0 licensed and open source for ease of integration, reuse and transparency. 48 | 49 | ## Hardware requirements 50 | 51 | - Operating system 52 | 53 | - iOS 9.3+, tested up to iOS 14.6. 54 | - Android 5.0+, tested up to Android 10.0 (API level 29). 55 | 56 | - Hardware 57 | 58 | - Apple iPhone 4S+, tested up to iPhone 11 Pro. 59 | - Android phones with BLE, including phones that do not support BLE advertising (circa 35% in UK). 60 | 61 | ## Quick start 62 | 63 | Please see the [developer guide](https://heraldprox.io/guide/add) 64 | for how to integrate your app to Herald. 65 | 66 | ## Test results 67 | 68 | For current and historic test and efficacy results please see the 69 | [Efficacy section](https://heraldprox.io/efficacy/results) 70 | of the Herald website. 71 | 72 | ## References 73 | 74 | Ferretti, L., Wymant, C., Kendall, M., Zhao, L., Nurtay, A., Abeler-Dörner, L., Parker, M., Bonsall, D., and Fraser, C. (2020) "Quantifying SARS-CoV-2 transmission suggests epidemic control with digital contact tracing", *Science*, vol. 368, no. 6491, New York. 75 | 76 | Statcounter 2020, *Mobile Operating System Market Share*, Statcounter Global Stats, viewed August 2020, 77 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/Security/Encryption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Encryption.swift 3 | // 4 | // Copyright 2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import CommonCrypto 10 | 11 | /// Cryptographically secure encryption and decryption algorithm 12 | public protocol Encryption { 13 | 14 | /// Encrypt data 15 | func encrypt(data: Data, with: EncryptionKey) -> Data? 16 | 17 | /// Decrypt data 18 | func decrypt(data: Data, with: EncryptionKey) -> Data? 19 | } 20 | 21 | public typealias EncryptionKey = Data 22 | 23 | /// AES128 encryption algorithm 24 | public class AES128: Encryption { 25 | private let random: PseudoRandomFunction 26 | 27 | public init(_ random: PseudoRandomFunction = SecureRandomFunction()) { 28 | self.random = random 29 | } 30 | 31 | // MARK: - Encryption 32 | 33 | public func encrypt(data: Data, with: EncryptionKey) -> Data? { 34 | // Convert key data to hash to ensure key length is 256 bits 35 | let key = SHA256().hash(with) 36 | // Generate random initialisation vector (128 bits = 16 bytes) 37 | var iv = Data(repeating: 0, count: 16) 38 | guard random.nextBytes(&iv) else { 39 | return nil 40 | } 41 | // Encrypt data with key and iv 42 | guard let encrypted = crypt(input: data, key: key, iv: iv, operation: CCOperation(kCCEncrypt)) else { 43 | return nil 44 | } 45 | // Build result = iv + encrypted 46 | var result = Data() 47 | result.append(iv) 48 | result.append(encrypted) 49 | return result 50 | } 51 | 52 | public func decrypt(data: Data, with: EncryptionKey) -> Data? { 53 | // Convert key data to hash to ensure key length is 256 bits 54 | let key = SHA256().hash(with) 55 | // Get iv from first 16 bytes of data 56 | guard data.count > 16 else { 57 | return nil 58 | } 59 | let iv = data.prefix(16) 60 | // Get encrypted data after iv 61 | let encrypted = data.suffix(from: 16) 62 | // Decrypt data 63 | guard let decrypted = crypt(input: encrypted, key: key, iv: iv, operation: CCOperation(kCCDecrypt)) else { 64 | return nil 65 | } 66 | return decrypted 67 | } 68 | 69 | // MARK: - Internal functions 70 | 71 | /// AES encryption/decryption algorithm 72 | /// NCSC Foundation Profile for TLS requires encryption with AES with 128-bit key in CBC mode 73 | private func crypt(input: Data, key: Data, iv: Data, operation: CCOperation) -> Data? { 74 | // Key must be 16, 24, or 32 bytes (128, 192, or 256 bits) 75 | guard [16,24,32].contains(key.count) else { 76 | return nil 77 | } 78 | // Initialisation vector (IV) must be 16 bytes (128 bits) 79 | guard iv.count == 16 else { 80 | return nil 81 | } 82 | // Prepare output buffer (minimum 16 bytes) 83 | var outputCount = max(16, input.count * 2) 84 | var output = Array(repeating: 0, count: outputCount) 85 | // Use CommonCrypto to perform cryptographic operation 86 | let inputPointer = (input as NSData).bytes 87 | let keyPointer = (key as NSData).bytes 88 | let ivPointer = (iv as NSData).bytes 89 | let status = CCCrypt(operation, CCAlgorithm(kCCAlgorithmAES128), CCOptions(kCCOptionPKCS7Padding), 90 | keyPointer, key.count, ivPointer, 91 | inputPointer, input.count, 92 | &output, output.count, &outputCount) 93 | guard status == kCCSuccess else { 94 | return nil 95 | } 96 | // Convert CommonCrypto output to Data 97 | var outputData: Data? 98 | output.withUnsafeBufferPointer() { pointer in 99 | if let baseAddress = pointer.baseAddress { 100 | outputData = Data(bytes: baseAddress, count: outputCount) 101 | } 102 | } 103 | return outputData 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/BLE/BLEUtilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BLEUtilities.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import CoreBluetooth 10 | 11 | /** 12 | Extension to make the state human readable in logs. 13 | */ 14 | @available(iOS 10.0, *) 15 | extension CBManagerState: CustomStringConvertible { 16 | /** 17 | Get plain text description of state. 18 | */ 19 | public var description: String { 20 | switch self { 21 | case .poweredOff: return ".poweredOff" 22 | case .poweredOn: return ".poweredOn" 23 | case .resetting: return ".resetting" 24 | case .unauthorized: return ".unauthorized" 25 | case .unknown: return ".unknown" 26 | case .unsupported: return ".unsupported" 27 | @unknown default: return "undefined" 28 | } 29 | } 30 | } 31 | 32 | extension CBPeripheralManagerState : CustomStringConvertible { 33 | /** 34 | Get plain text description of state. 35 | */ 36 | public var description: String { 37 | switch self { 38 | case .poweredOff: return ".poweredOff" 39 | case .poweredOn: return ".poweredOn" 40 | case .resetting: return ".resetting" 41 | case .unauthorized: return ".unauthorized" 42 | case .unknown: return ".unknown" 43 | case .unsupported: return ".unsupported" 44 | @unknown default: return "undefined" 45 | } 46 | } 47 | } 48 | 49 | extension CBCentralManagerState : CustomStringConvertible { 50 | /** 51 | Get plain text description of state. 52 | */ 53 | public var description: String { 54 | switch self { 55 | case .poweredOff: return ".poweredOff" 56 | case .poweredOn: return ".poweredOn" 57 | case .resetting: return ".resetting" 58 | case .unauthorized: return ".unauthorized" 59 | case .unknown: return ".unknown" 60 | case .unsupported: return ".unsupported" 61 | @unknown default: return "undefined" 62 | } 63 | } 64 | } 65 | 66 | 67 | /** 68 | Extension to make the state human readable in logs. 69 | */ 70 | extension CBPeripheralState: CustomStringConvertible { 71 | /** 72 | Get plain text description fo state. 73 | */ 74 | public var description: String { 75 | switch self { 76 | case .connected: return ".connected" 77 | case .connecting: return ".connecting" 78 | case .disconnected: return ".disconnected" 79 | case .disconnecting: return ".disconnecting" 80 | @unknown default: return "undefined" 81 | } 82 | } 83 | } 84 | 85 | /** 86 | Extension to make the time intervals more human readable in code. 87 | */ 88 | extension TimeInterval { 89 | public static var never: TimeInterval { get { TimeInterval(Int.max) } } 90 | public static var fortnight: TimeInterval { get { TimeInterval(1209600) }} 91 | public static var week: TimeInterval { get { TimeInterval(604800) }} 92 | public static var day: TimeInterval { get { TimeInterval(86400) } } 93 | public static var hour: TimeInterval { get { TimeInterval(3600) } } 94 | public static var minute: TimeInterval { get { TimeInterval(60) } } 95 | } 96 | 97 | /** 98 | Time interval samples for collecting elapsed time statistics. 99 | */ 100 | class TimeIntervalSample : SampleStatistics { 101 | private var startTime: Date? 102 | private var timestamp: Date? 103 | var period: TimeInterval? { get { 104 | (startTime == nil ? nil : timestamp?.timeIntervalSince(startTime!)) 105 | }} 106 | 107 | override var description: String { get { 108 | let sPeriod = (period == nil ? "-" : period!.description) 109 | return super.description + ",period=" + sPeriod 110 | }} 111 | 112 | /** 113 | Add elapsed time since last call to add() as sample. 114 | */ 115 | func add() { 116 | guard timestamp != nil else { 117 | timestamp = Date() 118 | startTime = timestamp 119 | return 120 | } 121 | let now = Date() 122 | if let timestamp = timestamp { 123 | add(now.timeIntervalSince(timestamp)) 124 | } 125 | timestamp = now 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/EventTimeIntervalLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventTimeIntervalLog.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// CSV log of events for analysis and visualisation 11 | public class EventTimeIntervalLog: SensorDelegateLogger { 12 | private let payloadData: PayloadData 13 | private let eventType: EventTimeIntervalLogEventType 14 | private var targetIdentifierToPayload: [TargetIdentifier:String] = [:] 15 | private var payloadToTime: [String:Date] = [:] 16 | private var payloadToSample: [String:SampleStatistics] = [:] 17 | 18 | public init(filename: String, payloadData: PayloadData, eventType: EventTimeIntervalLogEventType) { 19 | self.payloadData = payloadData 20 | self.eventType = eventType 21 | super.init(filename: filename) 22 | } 23 | 24 | private func add(payload: String) { 25 | guard let time = payloadToTime[payload], let sample = payloadToSample[payload] else { 26 | payloadToTime[payload] = Date() 27 | payloadToSample[payload] = SampleStatistics() 28 | return 29 | } 30 | let now = Date() 31 | payloadToTime[payload] = now 32 | sample.add(Double(now.timeIntervalSince(time))) 33 | write() 34 | } 35 | 36 | private func write() { 37 | var content = "event,central,peripheral,count,mean,sd,min,max\n" 38 | var payloadList: [String] = [] 39 | let event = csv(eventType.rawValue) 40 | let centralPayload = csv(payloadData.shortName) 41 | payloadToSample.keys.forEach() { payload in 42 | guard payload != payloadData.shortName else { 43 | return 44 | } 45 | payloadList.append(payload) 46 | } 47 | payloadList.sort() 48 | payloadList.forEach() { payload in 49 | guard let sample = payloadToSample[payload] else { 50 | return 51 | } 52 | guard let mean = sample.mean, let sd = sample.standardDeviation, let min = sample.min, let max = sample.max else { 53 | return 54 | } 55 | content.append("\(event),\(centralPayload),\(csv(payload)),\(sample.count),\(mean),\(sd),\(min),\(max)\n") 56 | } 57 | overwrite(content) 58 | } 59 | 60 | 61 | // MARK:- SensorDelegate 62 | 63 | public override func sensor(_ sensor: SensorType, didRead: PayloadData, fromTarget: TargetIdentifier) { 64 | let payload = didRead.shortName 65 | targetIdentifierToPayload[fromTarget] = payload 66 | guard eventType == .read else { 67 | return 68 | } 69 | add(payload: payload) 70 | } 71 | 72 | public override func sensor(_ sensor: SensorType, didDetect: TargetIdentifier) { 73 | guard eventType == .detect, let payload = targetIdentifierToPayload[didDetect] else { 74 | return 75 | } 76 | add(payload: payload) 77 | } 78 | 79 | public override func sensor(_ sensor: SensorType, didMeasure: Proximity, fromTarget: TargetIdentifier) { 80 | guard eventType == .measure, let payload = targetIdentifierToPayload[fromTarget] else { 81 | return 82 | } 83 | add(payload: payload) 84 | } 85 | 86 | public override func sensor(_ sensor: SensorType, didShare: [PayloadData], fromTarget: TargetIdentifier) { 87 | if eventType == .share, let payload = targetIdentifierToPayload[fromTarget] { 88 | add(payload: payload) 89 | } else if eventType == .sharedPeer { 90 | didShare.forEach() { sharedPayload in 91 | let payload = sharedPayload.shortName 92 | add(payload: payload) 93 | } 94 | } 95 | } 96 | 97 | public override func sensor(_ sensor: SensorType, didVisit: Location?) { 98 | guard eventType == .visit else { 99 | return 100 | } 101 | add(payload: payloadData.shortName) 102 | } 103 | } 104 | 105 | /// Event type to log in event time interval log 106 | public enum EventTimeIntervalLogEventType : String { 107 | case detect 108 | case read 109 | case measure 110 | case share 111 | case sharedPeer 112 | case visit 113 | } 114 | -------------------------------------------------------------------------------- /Herald/HeraldTests/BLEAdvertParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BLEAdvertParserTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class BLEAdvertParserTests: XCTestCase { 12 | 13 | // MARK: Low level individual parsing functions 14 | 15 | func testDataSubsetBigEndian() throws { 16 | let data = Data([0,1,5,6,7,8,12,13,14]) 17 | XCTAssertEqual(5, data[2]) 18 | XCTAssertEqual(6, data[3]) 19 | XCTAssertEqual(7, data[4]) 20 | XCTAssertEqual(8, data[5]) 21 | let result = BLEAdvertParser.subDataBigEndian(data,2,4) 22 | XCTAssertEqual(4, result.count) 23 | XCTAssertEqual(5, result[0]) 24 | XCTAssertEqual(6, result[1]) 25 | XCTAssertEqual(7, result[2]) 26 | XCTAssertEqual(8, result[3]) 27 | } 28 | 29 | func testDataSubsetLittleEndian() throws { 30 | let data = Data([0,1,5,6,7,8,12,13,14]) 31 | let result = BLEAdvertParser.subDataLittleEndian(data,2,4) 32 | XCTAssertEqual(4, result.count) 33 | XCTAssertEqual(8, result[0]) 34 | XCTAssertEqual(7, result[1]) 35 | XCTAssertEqual(6, result[2]) 36 | XCTAssertEqual(5, result[3]) 37 | } 38 | 39 | func testDataSubsetBigEndianOverflow() throws { 40 | let data = Data([0,1,5,6,7]) 41 | let result = BLEAdvertParser.subDataBigEndian(data,2,4) 42 | XCTAssertEqual(0, result.count) 43 | } 44 | 45 | func testDataSubsetLittleEndianOverflow() throws { 46 | let data = Data([0,1,5,6,7]) 47 | let result = BLEAdvertParser.subDataLittleEndian(data,2,4) 48 | XCTAssertEqual(0, result.count) 49 | } 50 | 51 | func testDataSubsetBigEndianLowIndex() throws { 52 | let data = Data([0,1,5,6,7]) 53 | let result = BLEAdvertParser.subDataBigEndian(data,-1,4) 54 | XCTAssertEqual(0, result.count) 55 | } 56 | 57 | func testDataSubsetLittleEndianLowIndex() throws { 58 | let data = Data([0,1,5,6,7]) 59 | let result = BLEAdvertParser.subDataLittleEndian(data,-1,4) 60 | XCTAssertEqual(0, result.count) 61 | } 62 | 63 | func testDataSubsetBigEndianHighIndex() throws { 64 | let data = Data([0,1,5,6,7]) 65 | let result = BLEAdvertParser.subDataBigEndian(data,5,4) 66 | XCTAssertEqual(0, result.count) 67 | } 68 | 69 | func testDataSubsetLittleEndianHighIndex() throws { 70 | let data = Data([0,1,5,6,7]) 71 | let result = BLEAdvertParser.subDataLittleEndian(data,5,4) 72 | XCTAssertEqual(0, result.count) 73 | } 74 | 75 | func testDataSubsetBigEndianLargeLength() throws { 76 | let data = Data([0,1,5,6,7]) 77 | let result = BLEAdvertParser.subDataBigEndian(data,2,4) 78 | XCTAssertEqual(0, result.count) 79 | } 80 | 81 | func testDataSubsetLittleEndianLargeLength() throws { 82 | let data = Data([0,1,5,6,7]) 83 | let result = BLEAdvertParser.subDataLittleEndian(data,2,4) 84 | XCTAssertEqual(0, result.count) 85 | } 86 | 87 | func testDataSubsetBigEndianEmptyData() throws { 88 | let data = Data() 89 | let result = BLEAdvertParser.subDataBigEndian(data,0,1) 90 | XCTAssertEqual(0, result.count) 91 | } 92 | 93 | func testDataSubsetLittleEndianEmptyData() throws { 94 | let data = Data() 95 | let result = BLEAdvertParser.subDataLittleEndian(data,0,1) 96 | XCTAssertEqual(0, result.count) 97 | } 98 | 99 | func testExtractMessages_iPhoneX_F() throws { 100 | let raw = Data(hexEncodedString: 101 | "02011A020A0C0BFF4C001006071EA3DD89E014FF4C00010000000000000000" + 102 | "00002000000000000000000000000000000000000000000000000000000000")! 103 | let segments = BLEAdvertParser.extractSegments(raw, 0) 104 | print(segments.map({ $0.description })) 105 | let manufacturerDataSegments = BLEAdvertParser.extractManufacturerData(segments: segments) 106 | print(manufacturerDataSegments.map({ $0.description })) 107 | XCTAssertEqual("1006071EA3DD89E0", manufacturerDataSegments[0].data.hexEncodedString) 108 | XCTAssertEqual("0100000000000000000000200000000000", manufacturerDataSegments[1].data.hexEncodedString) 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/EventLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventLog.swift 3 | // 4 | // Copyright 2021-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Generic event log with optional data retention enforcement functions 11 | public class EventLog: SensorDelegateLogger { 12 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Data.EventLog") 13 | private let queue: DispatchQueue 14 | public var events: [T] = [] 15 | 16 | public override init() { 17 | queue = DispatchQueue(label: "Sensor.Data.EventLog") 18 | super.init() 19 | } 20 | 21 | public override init(filename: String) { 22 | queue = DispatchQueue(label: "Sensor.Data.EventLog(\(filename))") 23 | super.init(filename: filename) 24 | contentsOf().split(separator: "\n").forEach { line in 25 | if let event = T(String(line)) { 26 | events.append(event) 27 | } 28 | } 29 | logger.debug("Loaded historic events (count=\(events.count))") 30 | } 31 | 32 | private func writerHeader() { 33 | if empty() { 34 | write(T.csvHeader) 35 | } 36 | } 37 | 38 | public func append(_ event: T) { 39 | logger.debug("append(\(event.csvString))") 40 | queue.sync { 41 | write(event.csvString) 42 | events.append(event) 43 | } 44 | } 45 | 46 | /// Get records from start date (inclusive) to end date (exclusive) 47 | public func subdata(start: Date, end: Date) -> [T] { 48 | queue.sync { 49 | let subdata = events.filter({ $0.timestamp >= start && $0.timestamp < end }) 50 | return subdata 51 | } 52 | } 53 | 54 | /// Get all encounters from start date (inclusive) 55 | public func subdata(start: Date) -> [T] { 56 | queue.sync { 57 | let subdata = events.filter({ $0.timestamp >= start }) 58 | return subdata 59 | } 60 | } 61 | 62 | /// Remove all log records before date (exclusive). Use this function to implement data retention policy. 63 | public func remove(before: Date) { 64 | queue.sync { 65 | var content = "\(T.csvHeader)\n" 66 | let subdata = events.filter({ $0.timestamp >= before }) 67 | subdata.forEach { event in 68 | content.append(event.csvString) 69 | content.append("\n") 70 | } 71 | overwrite(content) 72 | events = subdata 73 | } 74 | } 75 | 76 | /// Remove all log records before retention period. 77 | public func removeBefore(retention: TimeInterval) { 78 | remove(before: Date().addingTimeInterval(-retention)) 79 | } 80 | 81 | // MARK:- Analysis functions 82 | 83 | /// Get events in time windows 84 | public func reduce(into timeWindow: TimeInterval) -> [(time: Date, events: [T])] { 85 | var result: [(Date,[T])] = [] 86 | var currentTimeWindow = Date.distantPast 87 | var eventsInTimeWindow: [T] = [] 88 | let divisor = Int(timeWindow) 89 | events.forEach { event in 90 | let timeWindow = Date(timeIntervalSince1970: TimeInterval(Int(event.timestamp.timeIntervalSince1970).dividedReportingOverflow(by: divisor).partialValue * divisor)) 91 | if timeWindow != currentTimeWindow { 92 | if !eventsInTimeWindow.isEmpty { 93 | result.append((currentTimeWindow, eventsInTimeWindow)) 94 | eventsInTimeWindow = [] 95 | } 96 | currentTimeWindow = timeWindow 97 | } 98 | eventsInTimeWindow.append(event) 99 | } 100 | if !eventsInTimeWindow.isEmpty { 101 | result.append((currentTimeWindow, eventsInTimeWindow)) 102 | } 103 | return result 104 | } 105 | } 106 | 107 | /// Event for logging 108 | public protocol Event { 109 | var timestamp: Date { get } 110 | /// Get CSV header for event data including timestamp 111 | static var csvHeader: String { get } 112 | /// Get CSV representation of event data including timestamp 113 | var csvString: String { get } 114 | /// Parse CSV representation of event data to recreate event 115 | init?(_ csvString: String) 116 | } 117 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Data/TextFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFile.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class TextFile: Resettable { 11 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Data.TextFile") 12 | public let url: URL? 13 | private let queue: DispatchQueue 14 | 15 | public init(filename: String) { 16 | url = try? FileManager.default 17 | .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 18 | .appendingPathComponent(filename) 19 | queue = DispatchQueue(label: "Sensor.Data.TextFile(\(filename))") 20 | } 21 | 22 | public func reset() { 23 | overwrite("") 24 | } 25 | 26 | public static func removeAll() -> Bool { 27 | let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "Data.TextFile") 28 | guard let url = try? FileManager.default 29 | .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { 30 | return true 31 | } 32 | guard let files = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) else { 33 | return true 34 | } 35 | var success = true 36 | for file in files { 37 | do { 38 | try FileManager.default.removeItem(at: file) 39 | logger.debug("Remove file successful (folder=\(url),file=\(file.lastPathComponent))"); 40 | } catch { 41 | logger.fault("Remove file failed (folder=\(url),file=\(file.lastPathComponent))"); 42 | success = false 43 | } 44 | } 45 | return success 46 | } 47 | 48 | /// Get contents of file 49 | func contentsOf() -> String { 50 | queue.sync { 51 | guard let file = url else { 52 | return "" 53 | } 54 | guard let contents = try? String(contentsOf: file, encoding: .utf8) else { 55 | return "" 56 | } 57 | return contents 58 | } 59 | } 60 | 61 | func empty() -> Bool { 62 | guard let file = url else { 63 | return true 64 | } 65 | guard FileManager.default.fileExists(atPath: file.path) else { 66 | return true 67 | } 68 | guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path), 69 | let size = attributes[FileAttributeKey.size] as? UInt64 else { 70 | return true 71 | } 72 | return size == 0 73 | } 74 | 75 | /// Append line to new or existing file 76 | func write(_ line: String) { 77 | queue.sync { 78 | guard let file = url else { 79 | return 80 | } 81 | guard let data = (line+"\n").data(using: .utf8) else { 82 | return 83 | } 84 | if FileManager.default.fileExists(atPath: file.path) { 85 | if let fileHandle = try? FileHandle(forWritingTo: file) { 86 | 87 | if #available(iOS 13.4, *) { 88 | try? fileHandle.seekToEnd() 89 | try? fileHandle.write(contentsOf: data) 90 | try? fileHandle.close() 91 | } else { 92 | fileHandle.seekToEndOfFile() 93 | fileHandle.write(data) 94 | fileHandle.closeFile() 95 | } 96 | } 97 | } else { 98 | try? data.write(to: file, options: .atomicWrite) 99 | } 100 | } 101 | } 102 | 103 | /// Overwrite file content 104 | func overwrite(_ content: String) { 105 | queue.sync { 106 | guard let file = url else { 107 | return 108 | } 109 | guard let data = content.data(using: .utf8) else { 110 | return 111 | } 112 | try? data.write(to: file, options: .atomicWrite) 113 | } 114 | } 115 | 116 | /// Quote value for CSV output if required. 117 | static func csv(_ value: String) -> String { 118 | guard value.contains(",") || value.contains("\"") || value.contains("'") || value.contains("’") else { 119 | return value 120 | } 121 | return "\"" + value + "\"" 122 | 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/SensorArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SensorArray.swift 3 | // 4 | // Copyright 2020-2023 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | /// Sensor array for combining multiple detection and tracking methods. 12 | public class SensorArray : NSObject, Sensor { 13 | public static let deviceDescription = "\(UIDevice.current.name) (iOS \(UIDevice.current.systemVersion))" 14 | public let payloadData: PayloadData? 15 | private let logger = ConcreteSensorLogger(subsystem: "Sensor", category: "SensorArray") 16 | private let concreteBle: ConcreteBLESensor 17 | private var sensorArray: [Sensor] = [] 18 | private let delegateQueue = DispatchQueue(label: "Sensor.SensorArray.DelegateQueue") 19 | private var delegates: [SensorDelegate] = [] 20 | 21 | public init(_ payloadDataSupplier: PayloadDataSupplier) { 22 | logger.debug("init") 23 | // Mobility sensor enables background BLE advert detection 24 | // - This is optional because an Android device can act as a relay, 25 | // but enabling location sensor will enable direct iOS-iOS detection in background. 26 | // - Please note, the actual location is not used or recorded by HERALD. 27 | if let mobilitySensorResolution = BLESensorConfiguration.mobilitySensorEnabled { 28 | if BLESensorConfiguration.standardHeraldServiceDetectionEnabled { 29 | sensorArray.append(ConcreteMobilitySensor(resolution: mobilitySensorResolution, rangeForBeacon: UUID(uuidString: BLESensorConfiguration.linuxFoundationServiceUUID.uuidString))) 30 | } 31 | if let csuuid = BLESensorConfiguration.customServiceUUID, BLESensorConfiguration.customServiceDetectionEnabled { 32 | sensorArray.append(ConcreteMobilitySensor(resolution: mobilitySensorResolution, rangeForBeacon: UUID(uuidString: csuuid.uuidString))) 33 | } 34 | } 35 | // BLE sensor for detecting and tracking proximity 36 | concreteBle = ConcreteBLESensor(payloadDataSupplier) 37 | sensorArray.append(concreteBle) 38 | 39 | // Payload data at initiation time for identifying this device in the logs 40 | payloadData = payloadDataSupplier.payload(PayloadTimestamp(), device: nil) 41 | super.init() 42 | logger.debug("device (os=\(UIDevice.current.systemName)\(UIDevice.current.systemVersion),model=\(deviceModel()))") 43 | 44 | // Inertia sensor configured for automated RSSI-distance calibration data capture 45 | if BLESensorConfiguration.inertiaSensorEnabled { 46 | logger.debug("Inertia sensor enabled"); 47 | sensorArray.append(ConcreteInertiaSensor()); 48 | add(delegate: CalibrationLog(filename: "calibration.csv")); 49 | } 50 | 51 | if let payloadData = payloadData { 52 | logger.info("DEVICE (payloadPrefix=\(payloadData.shortName),description=\(SensorArray.deviceDescription))") 53 | } else { 54 | logger.info("DEVICE (payloadPrefix=EMPTY,description=\(SensorArray.deviceDescription))") 55 | } 56 | } 57 | 58 | public func coordinationProvider() -> CoordinationProvider? { 59 | // Array does not have a coordination provider 60 | return nil 61 | } 62 | 63 | private func deviceModel() -> String { 64 | var deviceInformation = utsname() 65 | uname(&deviceInformation) 66 | let mirror = Mirror(reflecting: deviceInformation.machine) 67 | return mirror.children.reduce("") { identifier, element in 68 | guard let value = element.value as? Int8, value != 0 else { 69 | return identifier 70 | } 71 | return identifier + String(UnicodeScalar(UInt8(value))) 72 | } 73 | } 74 | 75 | public func immediateSend(data: Data, _ targetIdentifier: TargetIdentifier) -> Bool { 76 | return concreteBle.immediateSend(data: data,targetIdentifier); 77 | } 78 | 79 | public func immediateSendAll(data: Data) -> Bool { 80 | return concreteBle.immediateSendAll(data: data); 81 | } 82 | 83 | public func add(delegate: SensorDelegate) { 84 | delegates.append(delegate) 85 | sensorArray.forEach { $0.add(delegate: delegate) } 86 | } 87 | 88 | public func start() { 89 | logger.debug("start") 90 | sensorArray.forEach { $0.start() } 91 | delegates.forEach { $0.sensor(.ARRAY, didUpdateState: .on)} 92 | } 93 | 94 | public func stop() { 95 | logger.debug("stop") 96 | sensorArray.forEach { $0.stop() } 97 | delegates.forEach { $0.sensor(.ARRAY, didUpdateState: .off)} 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Herald/Herald.xcodeproj/xcshareddata/xcschemes/Herald.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 31 | 32 | 33 | 39 | 45 | 46 | 47 | 48 | 49 | 55 | 56 | 59 | 60 | 61 | 62 | 64 | 70 | 71 | 72 | 73 | 74 | 84 | 86 | 92 | 93 | 94 | 95 | 101 | 102 | 108 | 109 | 110 | 111 | 113 | 114 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/Payload/Extended/ExtendedData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtendedData.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | import Accelerate 10 | 11 | /// ExtendedData - could be empty 12 | public protocol ExtendedData { 13 | func hasData() -> Bool 14 | func addSection(code: ExtendedDataSegmentCode, value: UInt8) 15 | func addSection(code: ExtendedDataSegmentCode, value: UInt16) 16 | @available(iOS 14.0, *) 17 | func addSection(code: ExtendedDataSegmentCode, value: Float16) 18 | func addSection(code: ExtendedDataSegmentCode, value: Float32) 19 | func addSection(code: ExtendedDataSegmentCode, value: String) 20 | /// Catch-all for all other or future types 21 | func addSection(code: ExtendedDataSegmentCode, value: Data) 22 | 23 | func payload() -> PayloadData? 24 | } 25 | 26 | public typealias ExtendedDataSegmentCode = UInt8 27 | 28 | /// CURRENT complete list, across all version, with CURRENT names 29 | public enum ExtendedDataSegmentCodes : UInt8 { 30 | case TextPremises = 0x10 31 | case TextLocation = 0x11 32 | case TextArea = 0x12 33 | case LocationUrl = 0x13 34 | } 35 | 36 | /// V1 codes, with names and codes at the time 37 | public enum ExtendedDataSegmentCodesV1 : UInt8 { 38 | case TextPremises = 0x10 39 | case TextLocation = 0x11 40 | case TextArea = 0x12 41 | case LocationUrl = 0x13 42 | } 43 | 44 | public class ConcreteExtendedDataSectionV1 { 45 | public var code: UInt8 46 | public var length: UInt8 47 | public var data: Data 48 | 49 | public init(code: UInt8, length: UInt8, data: Data) { 50 | self.code = code 51 | self.length = length 52 | self.data = data 53 | } 54 | } 55 | 56 | /// Beacon payload data supplier. 57 | public class ConcreteExtendedDataV1 : ExtendedData { 58 | var payloadData : PayloadData 59 | 60 | public init() { 61 | payloadData = PayloadData() // empty 62 | } 63 | 64 | public init(_ unparsedData: PayloadData) { 65 | self.payloadData = unparsedData 66 | } 67 | 68 | public func payload() -> PayloadData? { 69 | return payloadData 70 | } 71 | 72 | public func hasData() -> Bool { 73 | return 0 != payloadData.count 74 | } 75 | 76 | public func addSection(code: ExtendedDataSegmentCode, value: Int8) { 77 | payloadData.append(code) 78 | payloadData.append(UInt8(1)) 79 | payloadData.append(value) 80 | } 81 | 82 | 83 | public func addSection(code: ExtendedDataSegmentCode, value: UInt8) { 84 | payloadData.append(code) 85 | payloadData.append(UInt8(1)) 86 | payloadData.append(value) 87 | } 88 | 89 | public func addSection(code: ExtendedDataSegmentCode, value: UInt16) { 90 | payloadData.append(code) 91 | payloadData.append(UInt8(2)) 92 | payloadData.append(value) 93 | } 94 | 95 | @available(iOS 14.0, *) 96 | public func addSection(code: ExtendedDataSegmentCode, value: Float16) { 97 | payloadData.append(code) 98 | payloadData.append(UInt8(2)) 99 | payloadData.append(value) 100 | } 101 | 102 | public func addSection(code: ExtendedDataSegmentCode, value: Float32) { 103 | payloadData.append(code) 104 | payloadData.append(UInt8(4)) 105 | payloadData.append(value) 106 | } 107 | 108 | /// Maximum value supported is UInt8.max length 109 | public func addSection(code: ExtendedDataSegmentCode, value: String) { 110 | payloadData.append(code) 111 | payloadData.append(UInt8(value.count)) 112 | payloadData.append(value.data(using: .utf8)!) 113 | } 114 | 115 | /// Maximum value supported is UInt8.max length 116 | public func addSection(code: ExtendedDataSegmentCode, value: Data) { 117 | payloadData.append(code) 118 | payloadData.append(UInt8(value.count)) 119 | payloadData.append(value) 120 | } 121 | 122 | // MARK:- V1 only methods 123 | 124 | public func getSections() -> [ConcreteExtendedDataSectionV1] { 125 | var sections : [ConcreteExtendedDataSectionV1] = [] 126 | var pos = 0 127 | // read code 128 | while pos < payloadData.count { 129 | if payloadData.count - 2 <= pos { // at least 3 in length 130 | pos = payloadData.count 131 | continue 132 | } 133 | // read code 134 | let code = payloadData.data.uint8(pos)! 135 | pos = pos + 1 136 | // read length 137 | var length = payloadData.data[pos] 138 | pos = pos + 1 139 | // sanity check length 140 | if pos + Int(length) > payloadData.count { 141 | length = UInt8(payloadData.count - pos) 142 | } 143 | // extract data 144 | let data = payloadData.subdata(in: pos..<(pos+Int(length))) 145 | 146 | sections.append(ConcreteExtendedDataSectionV1(code: code, length: length, data: data)) 147 | 148 | // repeat 149 | pos = pos + Int(length) 150 | } 151 | return sections 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Herald/HeraldTests/VenueDiaryEventTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VenueDiaryEventTests.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | @testable import Herald 10 | 11 | class VenueDiaryEventTests: XCTestCase { 12 | 13 | func testSingleEvent() throws { 14 | // Set up 15 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: K.date("2020-09-24T10:00:00+0000")!) 16 | 17 | // Basic checks (this test only) 18 | XCTAssertEqual(826,evt.getCountry()) 19 | XCTAssertEqual(4,evt.getState()) 20 | XCTAssertEqual(12345,evt.getCode()) 21 | 22 | // Test specific checks 23 | XCTAssertFalse(evt.isRecordable()) 24 | XCTAssertFalse(evt.isClosed()) 25 | } 26 | func testTooShortForRecordingEvent() throws { 27 | // Set up 28 | let firstDate = K.date("2020-09-24T10:00:00+0000")! 29 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: firstDate) 30 | 31 | // Add a second check in underneath the default limit, minus a second (boundary check) 32 | let secondDateTime: Date = firstDate + BLESensorConfiguration.venueCheckInTimeLimit - TimeInterval(1) 33 | let validToExtendEvent = evt.addPresenceIfSameEvent(secondDateTime) 34 | 35 | // Test specific checks 36 | XCTAssertTrue(validToExtendEvent) 37 | XCTAssertFalse(evt.isRecordable()) 38 | XCTAssertFalse(evt.isClosed()) 39 | } 40 | func testRecordableButNotClosedEvent() throws { 41 | // Set up 42 | let firstDate = K.date("2020-09-24T10:00:00+0000")! 43 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: firstDate) 44 | 45 | // Add a second check in above the default limit, plus a second (boundary check) 46 | let secondDateTime: Date = firstDate + BLESensorConfiguration.venueCheckInTimeLimit + TimeInterval(1) 47 | let validToExtendEvent = evt.addPresenceIfSameEvent(secondDateTime) 48 | 49 | // WARNING: Test assumes CheckOutTimeLimit is GREATER THAN CheckInTimeLimit 50 | 51 | // Test specific checks 52 | XCTAssertTrue(validToExtendEvent) 53 | XCTAssertTrue(evt.isRecordable()) 54 | XCTAssertFalse(evt.isClosed()) 55 | } 56 | func testRecordableAndClosedEvent() throws { 57 | // Set up 58 | let firstDate = K.date("2020-09-24T10:00:00+0000")! 59 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: firstDate) 60 | 61 | // Add a second check in above the default limit, plus a second (boundary check) 62 | let secondDateTime: Date = firstDate + BLESensorConfiguration.venueCheckInTimeLimit + TimeInterval(1) 63 | let validToExtendEvent = evt.addPresenceIfSameEvent(secondDateTime) 64 | 65 | // Add a third event after the closed time limit 66 | let thirdDateTime: Date = secondDateTime + BLESensorConfiguration.venueCheckOutTimeLimit + TimeInterval(1) 67 | let validToExtendEventSecond = evt.addPresenceIfSameEvent(thirdDateTime) 68 | 69 | // WARNING: Test assumes CheckOutTimeLimit is GREATER THAN CheckInTimeLimit 70 | 71 | // Test specific checks 72 | XCTAssertTrue(validToExtendEvent) 73 | XCTAssertFalse(validToExtendEventSecond) 74 | XCTAssertTrue(evt.isRecordable()) 75 | XCTAssertTrue(evt.isClosed()) 76 | } 77 | func testRecordableAndClosedEventViaUpdate() throws { 78 | // Set up 79 | let firstDate = K.date("2020-09-24T10:00:00+0000")! 80 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: firstDate) 81 | 82 | // Add a second check in above the default limit, plus a second (boundary check) 83 | let secondDateTime: Date = firstDate + BLESensorConfiguration.venueCheckInTimeLimit + TimeInterval(1) 84 | let validToExtendEvent = evt.addPresenceIfSameEvent(secondDateTime) 85 | 86 | // Check state after event 87 | let thirdDateTime: Date = secondDateTime + BLESensorConfiguration.venueCheckOutTimeLimit + TimeInterval(1) 88 | evt.updateStateIfNecessary(at: thirdDateTime) 89 | 90 | // WARNING: Test assumes CheckOutTimeLimit is GREATER THAN CheckInTimeLimit 91 | 92 | // Test specific checks 93 | XCTAssertTrue(validToExtendEvent) 94 | XCTAssertTrue(evt.isRecordable()) 95 | XCTAssertTrue(evt.isClosed()) 96 | } 97 | func testNotRecordableAndClosedEvent() throws { 98 | // Set up 99 | let firstDate = K.date("2020-09-24T10:00:00+0000")! 100 | let evt = VenueDiaryEvent(country: 826,state: 4, venue: 12345, firstSeen: firstDate) 101 | 102 | // Add a second check in above the CLOSED limit, plus a second (boundary check) 103 | let secondDateTime: Date = firstDate + BLESensorConfiguration.venueCheckOutTimeLimit + TimeInterval(1) 104 | let validToExtendEvent = evt.addPresenceIfSameEvent(secondDateTime) 105 | 106 | // WARNING: Test assumes CheckOutTimeLimit is GREATER THAN CheckInTimeLimit 107 | 108 | // Test specific checks 109 | XCTAssertFalse(validToExtendEvent) 110 | XCTAssertFalse(evt.isRecordable()) 111 | XCTAssertTrue(evt.isClosed()) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Herald/Herald/Sensor/BLE/BLEAdvertParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BLEAdvertParser.swift 3 | // 4 | // Copyright 2020-2021 Herald Project Contributors 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | class BLEAdvertParser { 11 | 12 | static func extractSegments(_ raw: Data, _ offset: Int) -> [BLEAdvertSegment] { 13 | var position = offset 14 | var segments: [BLEAdvertSegment] = [] 15 | while (position < raw.count) { 16 | if ((position + 2) <= raw.count) { 17 | let segmentLength = Int(raw[position] & 0xFF) 18 | position+=1 19 | let segmentType = raw[position] & 0xFF 20 | position+=1 21 | // Note: Unsupported types are handled as 'unknown' 22 | // check reported length with actual remaining data length 23 | if ((position + segmentLength - 1) <= raw.count) { 24 | let segmentData = subDataBigEndian(raw, position, segmentLength - 1) // Note: type IS INCLUDED in length 25 | let rawData = Data(subDataBigEndian(raw, position - 2, segmentLength + 1)) 26 | position += (segmentLength - 1) 27 | segments.append(BLEAdvertSegment(type: BLEAdvertSegmentType(rawValue: segmentType) ?? .unknown, dataLength: segmentLength - 1, data: segmentData, raw: rawData)) 28 | } else { 29 | // error in data length - advance to end 30 | position = raw.count 31 | } 32 | } else { 33 | // invalid segment - advance to end 34 | position = raw.count 35 | } 36 | } 37 | return segments 38 | } 39 | 40 | static func extractManufacturerData(segments: [BLEAdvertSegment]) -> [BLEAdvertManufacturerData] { 41 | // find the manufacturerData code segment in the list 42 | var manufacturerData: [BLEAdvertManufacturerData] = [] 43 | segments.forEach() { segment in 44 | guard segment.type == .manufacturerData, segment.data.count >= 2 else { 45 | return // there may be a valid segment of same type... Happens for manufacturer data 46 | } 47 | // Create a manufacturer data segment 48 | let intValue = Int(((segment.data[1] & 0xFF) << 8) | (segment.data[0] & 0xFF)) 49 | manufacturerData.append(BLEAdvertManufacturerData(manufacturer: intValue, data: subDataBigEndian(segment.data,2,segment.dataLength - 2), raw: segment.raw)) 50 | } 51 | return manufacturerData; 52 | } 53 | 54 | static func subDataBigEndian(_ raw: Data, _ offset: Int, _ length: Int) -> Data { 55 | guard offset >= 0, length > 0 else { 56 | return Data() 57 | } 58 | guard offset + length <= raw.count else { 59 | return Data() 60 | } 61 | return raw.subdata(in: offset.. Data { 65 | guard offset >= 0, length > 0 else { 66 | return Data() 67 | } 68 | guard offset + length <= raw.count else { 69 | return Data() 70 | } 71 | return Data(raw.subdata(in: offset..