Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Features

- Add breadcrumbs when the hinge of foldable devices such as iPhone Duo opens, closes, or reaches the fully open or closed position, and report the current hinge status as `hinge_status` in the device context, which requires iOS 27.1 or later (#9426)

## 9.31.0

### Features
Expand Down
1 change: 1 addition & 0 deletions Sources/Sentry/SentryClient+EventContext.m
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ - (void)removeExtraDeviceContextFromEvent:(SentryEvent *)event
[device removeObjectForKey:@"charging"];
[device removeObjectForKey:@"battery_level"];
[device removeObjectForKey:@"thermal_state"];
[device removeObjectForKey:@"hinge_status"];
}];

[self modifyContext:event
Expand Down
11 changes: 10 additions & 1 deletion Sources/Swift/Helper/SentryExtraContextProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ internal import _SentryPrivate

#if (os(iOS)) && !SENTRY_NO_UI_FRAMEWORK
private let deviceWrapper: SentryUIDeviceWrapper
private let hingeObserver: SentryHingeObserving

init(memoryMetricsProvider: SentryMemoryMetricsProvider, processInfoWrapper: SentryProcessInfoSource, deviceWrapper: SentryUIDeviceWrapper, reachability: SentryReachability) {
init(memoryMetricsProvider: SentryMemoryMetricsProvider, processInfoWrapper: SentryProcessInfoSource, deviceWrapper: SentryUIDeviceWrapper, hingeObserver: SentryHingeObserving, reachability: SentryReachability) {
self.memoryMetricsProvider = memoryMetricsProvider
self.processInfoWrapper = processInfoWrapper
self.deviceWrapper = deviceWrapper
self.hingeObserver = hingeObserver
self.reachability = reachability
}
#else
Expand Down Expand Up @@ -80,6 +82,13 @@ internal import _SentryPrivate
extraDeviceContext["charging"] = NSNumber(value: deviceWrapper.batteryState == .charging)
extraDeviceContext["battery_level"] = NSNumber(value: Int(deviceWrapper.batteryLevel * 100))
}

// The hinge of foldable devices, such as iPhone Duo, is only known once UIKit reported it,
// which requires iOS 27.1 and a device with a hinge. The value mirrors the status of the
// hinge breadcrumbs: closed, partially_open, or fully_open.
if let hingeState = hingeObserver.currentState, hingeState.status != .unknown {
extraDeviceContext["hinge_status"] = hingeState.status.breadcrumbValue
}
#endif
return extraDeviceContext
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import UIKit
#endif

#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
typealias AutoBreadcrumbTrackingIntegrationProvider = SentryUIDeviceWrapperProvider & FileManagerProvider & NotificationCenterProvider
typealias AutoBreadcrumbTrackingIntegrationProvider = SentryUIDeviceWrapperProvider & FileManagerProvider & NotificationCenterProvider & HingeObserverProvider
#else
typealias AutoBreadcrumbTrackingIntegrationProvider = FileManagerProvider
#endif
Expand All @@ -15,6 +15,7 @@ final class SentryAutoBreadcrumbTrackingIntegration<Dependencies: AutoBreadcrumb

#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
private var systemEventBreadcrumbs: SentrySystemEventBreadcrumbs?
private var hingeBreadcrumbs: SentryHingeBreadcrumbs?
#endif // os(iOS) && !SENTRY_NO_UI_FRAMEWORK

init?(with options: Options, dependencies: Dependencies) {
Expand Down Expand Up @@ -54,6 +55,12 @@ final class SentryAutoBreadcrumbTrackingIntegration<Dependencies: AutoBreadcrumb
)
self.systemEventBreadcrumbs = systemEventBreadcrumbs
systemEventBreadcrumbs.start(with: self)

// Apple doesn't provide any easy way to detect if the device has a hinge,
// but it will never report a hinge state change if the device doesn't have a hinge.
let hingeBreadcrumbs = SentryHingeBreadcrumbs(hingeObserver: dependencies.hingeObserver)
self.hingeBreadcrumbs = hingeBreadcrumbs
hingeBreadcrumbs.start(with: self)
#endif // os(iOS) && !SENTRY_NO_UI_FRAMEWORK
}

Expand All @@ -64,6 +71,8 @@ final class SentryAutoBreadcrumbTrackingIntegration<Dependencies: AutoBreadcrumb
#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
systemEventBreadcrumbs?.stop()
systemEventBreadcrumbs = nil
hingeBreadcrumbs?.stop()
hingeBreadcrumbs = nil
#endif // os(iOS) && !SENTRY_NO_UI_FRAMEWORK
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
internal import _SentryPrivate

#if os(iOS) && !SENTRY_NO_UI_FRAMEWORK
import Foundation

/// Adds breadcrumbs when the hinge of a foldable device, such as iPhone Duo, opens or closes.
final class SentryHingeBreadcrumbs {
/// The hinge has to move at least this far, in radians, since the last breadcrumb before a
/// new breadcrumb is added. 45 degrees keeps the trail short while the device is being folded.
static let reportingThreshold = Double.pi / 4

private weak var delegate: SentryBreadcrumbDelegate?
private let hingeObserver: SentryHingeObserving

/// The hinge state at the last breadcrumb, or the first observed state before any breadcrumb.
/// Only accessed on the main thread, where hinge updates are delivered.
private var lastReportedState: SentryHingeState?

init(hingeObserver: SentryHingeObserving) {
self.hingeObserver = hingeObserver
}

func start(with delegate: SentryBreadcrumbDelegate) {
self.delegate = delegate
hingeObserver.start { [weak self] hingeState in
self?.hingeStateChanged(hingeState)
}
}

func stop() {
hingeObserver.stop()
delegate = nil
}

/**
* The hinge reports angle updates continuously while the device is being folded, so a
* breadcrumb is only added once the hinge moved at least `reportingThreshold` away from the
* angle of the last breadcrumb, or once it reaches the fully closed or fully open position.
* The first observed state only seeds the comparison, because it describes the current
* posture rather than a movement.
*/
private func hingeStateChanged(_ hingeState: SentryHingeState) {
guard hingeState.status != .unknown else {
SentrySDKLog.debug("Hinge status is unknown. Won't create hinge breadcrumb.")
return
}

guard let lastReportedState else {
lastReportedState = hingeState
return
}

let isEndPosition = hingeState.status == .closed || hingeState.status == .fullyOpen
if isEndPosition && hingeState.status == lastReportedState.status {
// The hinge can report an end position before its angle settles. The position was
// already reported, so only track the settled angle for the next movement.
self.lastReportedState = hingeState
return
}

let angleDelta = hingeState.angle - lastReportedState.angle
let reachedEndPosition = isEndPosition && hingeState.status != lastReportedState.status
guard reachedEndPosition || abs(angleDelta) >= Self.reportingThreshold else {
return
}
self.lastReportedState = hingeState

// The end positions define the direction on their own, which also covers the case of an
// end position being reached with a negligible angle change.
let direction: String
switch hingeState.status {
case .closed:
direction = "closing"
case .fullyOpen:
direction = "opening"
case .partiallyOpen, .unknown:
direction = angleDelta > 0 ? "opening" : "closing"
}

let crumb = Breadcrumb(level: .info, category: "device.event")
crumb.type = "system"
crumb.setData(value: "HINGE_STATE_CHANGE", key: "action")
crumb.setData(value: direction, key: "direction")
crumb.setData(value: hingeState.status.breadcrumbValue, key: "status")
crumb.setData(value: lastReportedState.status.breadcrumbValue, key: "previous_status")
delegate?.add(crumb)
}
}

#endif // os(iOS) && !SENTRY_NO_UI_FRAMEWORK
Loading
Loading