diff --git a/.github/workflows/sensors_plus.yaml b/.github/workflows/sensors_plus.yaml index 9ab18ab070..6663278cbf 100644 --- a/.github/workflows/sensors_plus.yaml +++ b/.github/workflows/sensors_plus.yaml @@ -114,6 +114,19 @@ jobs: - name: "Run Integration Test" run: ./.github/workflows/scripts/integration-test.sh ios sensors_plus_example + - name: "iOS Native Unit Tests" + working-directory: packages/sensors_plus/sensors_plus/example + run: | + # Reset the temporary Flutter test entrypoint before building the XCTest host. + flutter build ios --simulator --debug --config-only --target lib/main.dart + xcodebuild test -quiet \ + -workspace ios/Runner.xcworkspace \ + -scheme Runner \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:RunnerTests \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO + web_example_build: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/packages/sensors_plus/sensors_plus/example/ios/RunnerTests/RunnerTests.swift b/packages/sensors_plus/sensors_plus/example/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b6..adf1271c50 100644 --- a/packages/sensors_plus/sensors_plus/example/ios/RunnerTests/RunnerTests.swift +++ b/packages/sensors_plus/sensors_plus/example/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,54 @@ -import Flutter -import UIKit import XCTest +@testable import sensors_plus + class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + private let unixTime: TimeInterval = 1_700_000_000 + private let accuracyMicroseconds: Double = 1 + + func testConvertsUptimeToUnixMicroseconds() { + let actual = sensorTimestampToUnixMicroseconds( + 1000, currentUptime: 1000, currentUnixTime: unixTime) + + XCTAssertEqual(actual, unixTime * 1_000_000, accuracy: accuracyMicroseconds) + } + + func testRefreshesConversionAfterSimulatedSleep() { + let before = sensorTimestampToUnixMicroseconds( + 1000, currentUptime: 1000, currentUnixTime: unixTime) + // Two minutes pass in wall time while uptime is paused. A new sample is + // then measured ten uptime seconds later in the same app process. + let after = sensorTimestampToUnixMicroseconds( + 1010, currentUptime: 1010, currentUnixTime: unixTime + 130) + + XCTAssertEqual(after - before, 130_000_000, accuracy: accuracyMicroseconds) + } + + func testPreservesMeasurementTimeForDelayedDelivery() { + let actual = sensorTimestampToUnixMicroseconds( + 999.75, currentUptime: 1000, currentUnixTime: unixTime) + + XCTAssertEqual(actual, (unixTime - 0.25) * 1_000_000, accuracy: accuracyMicroseconds) + } + + func testPreservesSampleSpacingDespiteDifferentDeliveryDelays() { + let first = sensorTimestampToUnixMicroseconds( + 1000, currentUptime: 1001, currentUnixTime: unixTime + 1) + let second = sensorTimestampToUnixMicroseconds( + 1000.02, currentUptime: 1004, currentUnixTime: unixTime + 4) + + XCTAssertEqual(second - first, 20_000, accuracy: accuracyMicroseconds) + } + + func testUsesCurrentWallTimeAfterClockAdjustment() { + let before = sensorTimestampToUnixMicroseconds( + 1000, currentUptime: 1000, currentUnixTime: unixTime) + // The user sets the wall clock back one minute; the sensor clock does not reset. + let after = sensorTimestampToUnixMicroseconds( + 1000, currentUptime: 1000, currentUnixTime: unixTime - 60) + + XCTAssertEqual(after - before, -60_000_000, accuracy: accuracyMicroseconds) } } diff --git a/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift b/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift index 25c54b20b4..0c7f1eab4b 100644 --- a/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift +++ b/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/FPPStreamHandlerPlus.swift @@ -15,8 +15,6 @@ public protocol MotionStreamHandler: FlutterStreamHandler { var samplingPeriod: Int { get set } } -let timestampMicroAtBoot = (Date().timeIntervalSince1970 - ProcessInfo.processInfo.systemUptime) * 1000000 - func _initMotionManager() { if (_motionManager == nil) { _motionManager = CMMotionManager() @@ -39,8 +37,8 @@ func sendFlutter(x: Float64, y: Float64, z: Float64, timestamp: TimeInterval, si } // Even after [detachFromEngineForRegistrar] some events may still be received // and fired until fully detached. + let timestampSince1970Micro = sensorTimestampToUnixMicroseconds(timestamp) DispatchQueue.main.async { - let timestampSince1970Micro = timestampMicroAtBoot + (timestamp * 1000000) let triplet = [x, y, z, timestampSince1970Micro] triplet.withUnsafeBufferPointer { buffer in sink(FlutterStandardTypedData.init(float64: Data(buffer: buffer))) @@ -273,8 +271,8 @@ class FPPBarometerStreamHandlerPlus: NSObject, MotionStreamHandler { return } let pressure = data!.pressure.doubleValue * 10.0 // kPa to hPa (hectopascals) + let timestampSince1970Micro = sensorTimestampToUnixMicroseconds(data!.timestamp) DispatchQueue.main.async { - let timestampSince1970Micro = timestampMicroAtBoot + (data!.timestamp * 1000000) let pressureArray: [Double] = [pressure, timestampSince1970Micro] pressureArray.withUnsafeBufferPointer { buffer in sink(FlutterStandardTypedData.init(float64: Data(buffer: buffer))) diff --git a/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/SensorTimestamp.swift b/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/SensorTimestamp.swift new file mode 100644 index 0000000000..a82822ee2d --- /dev/null +++ b/packages/sensors_plus/sensors_plus/ios/sensors_plus/Sources/sensors_plus/SensorTimestamp.swift @@ -0,0 +1,17 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Foundation + +func sensorTimestampToUnixMicroseconds( + _ sensorTimestamp: TimeInterval, + currentUptime: TimeInterval = ProcessInfo.processInfo.systemUptime, + currentUnixTime: TimeInterval = Date().timeIntervalSince1970 +) -> Double { + // Uptime excludes sleep, so its offset from wall time must not be cached + // across callbacks. Preserve the measurement's age instead of timestamping + // it at delivery time. + let sampleAge = currentUptime - sensorTimestamp + return (currentUnixTime - sampleAge) * 1_000_000 +}