Avoid race conditions between the home screen's crash alert and Sentry's onLastRunStatusDetermined

By converting the `lastCrashEventID` to a publisher that the VM can just listen to instead of relying on start to match a pre-set event
This commit is contained in:
Stefan Ceriu
2026-08-13 09:25:27 +03:00
parent 9f648803f9
commit d4d74dd8f8
14 changed files with 33 additions and 32 deletions
@@ -1018,7 +1018,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
options.onLastRunStatusDetermined = { status, event in options.onLastRunStatusDetermined = { status, event in
guard case .didCrash = status, let event else { return } guard case .didCrash = status, let event else { return }
MXLog.error("Sentry detected a crash in the previous run: \(event.eventId.sentryIdString)") MXLog.error("Sentry detected a crash in the previous run: \(event.eventId.sentryIdString)")
bugReportService.lastCrashEventID = event.eventId.sentryIdString bugReportService.lastCrashEventIDSubject.send(event.eventId.sentryIdString)
} }
// Mirror every crash into our own logs (which ship with rageshakes) before it's sent to Sentry. // Mirror every crash into our own logs (which ship with rageshakes) before it's sent to Sentry.
@@ -17,5 +17,6 @@ extension BugReportServiceMock {
self.init() self.init()
isEnabled = configuration.isEnabled isEnabled = configuration.isEnabled
lastCrashEventIDSubject = .init(nil)
} }
} }
@@ -1809,12 +1809,11 @@ nonisolated class BugReportServiceMock: BugReportServiceProtocol, @unchecked Sen
set(value) { underlyingIsEnabled = value } set(value) { underlyingIsEnabled = value }
} }
nonisolated(unsafe) var underlyingIsEnabled: Bool! nonisolated(unsafe) var underlyingIsEnabled: Bool!
var crashedLastRun: Bool { var lastCrashEventIDSubject: CurrentValueSubject<String?, Never> {
get { return underlyingCrashedLastRun } get { return underlyingLastCrashEventIDSubject }
set(value) { underlyingCrashedLastRun = value } set(value) { underlyingLastCrashEventIDSubject = value }
} }
nonisolated(unsafe) var underlyingCrashedLastRun: Bool! nonisolated(unsafe) var underlyingLastCrashEventIDSubject: CurrentValueSubject<String?, Never>!
nonisolated(unsafe) var lastCrashEventID: String?
//MARK: - submitBugReport //MARK: - submitBugReport
@@ -39,8 +39,6 @@ enum HomeScreenCoordinatorAction {
final class HomeScreenCoordinator: CoordinatorProtocol { final class HomeScreenCoordinator: CoordinatorProtocol {
private var viewModel: HomeScreenViewModelProtocol private var viewModel: HomeScreenViewModelProtocol
// periphery:ignore - only used in release builds
private let bugReportService: BugReportServiceProtocol
private let actionsSubject: PassthroughSubject<HomeScreenCoordinatorAction, Never> = .init() private let actionsSubject: PassthroughSubject<HomeScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -54,9 +52,9 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
selectedRoomPublisher: parameters.selectedRoomPublisher, selectedRoomPublisher: parameters.selectedRoomPublisher,
appSettings: parameters.appSettings, appSettings: parameters.appSettings,
analyticsService: parameters.analyticsService, analyticsService: parameters.analyticsService,
bugReportService: parameters.bugReportService,
notificationManager: parameters.notificationManager, notificationManager: parameters.notificationManager,
userIndicatorController: parameters.userIndicatorController) userIndicatorController: parameters.userIndicatorController)
bugReportService = parameters.bugReportService
viewModel.actions viewModel.actions
.sink { [weak self] action in .sink { [weak self] action in
@@ -100,16 +98,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
// MARK: - Public // MARK: - Public
func start() {
#if !DEBUG
// Note: bugReportService.isEnabled doesn't determine if a user has opted in to Analytics/Sentry.
// Therefore we use lastCrashEventID as this will only be set if we have crash ID from Sentry.
if bugReportService.crashedLastRun, bugReportService.lastCrashEventID != nil {
viewModel.presentCrashedLastRunAlert()
}
#endif
}
func toPresentable() -> AnyView { func toPresentable() -> AnyView {
AnyView(HomeScreen(context: viewModel.context)) AnyView(HomeScreen(context: viewModel.context))
} }
@@ -17,6 +17,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private let userSession: UserSessionProtocol private let userSession: UserSessionProtocol
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never> private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
private let analyticsService: AnalyticsServiceProtocol private let analyticsService: AnalyticsServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let notificationManager: NotificationManagerProtocol private let notificationManager: NotificationManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
@@ -33,10 +34,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
selectedRoomPublisher: CurrentValuePublisher<String?, Never>, selectedRoomPublisher: CurrentValuePublisher<String?, Never>,
appSettings: AppSettings, appSettings: AppSettings,
analyticsService: AnalyticsServiceProtocol, analyticsService: AnalyticsServiceProtocol,
bugReportService: BugReportServiceProtocol,
notificationManager: NotificationManagerProtocol, notificationManager: NotificationManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol) { userIndicatorController: UserIndicatorControllerProtocol) {
self.userSession = userSession self.userSession = userSession
self.analyticsService = analyticsService self.analyticsService = analyticsService
self.bugReportService = bugReportService
self.appSettings = appSettings self.appSettings = appSettings
self.notificationManager = notificationManager self.notificationManager = notificationManager
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
@@ -141,6 +144,15 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.selectedSpaceFilter, on: self) .weakAssign(to: \.state.selectedSpaceFilter, on: self)
.store(in: &cancellables) .store(in: &cancellables)
bugReportService.lastCrashEventIDSubject
.compactMap { $0 }
.first()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.presentCrashedLastRunAlert()
}
.store(in: &cancellables)
Task { Task {
state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported
} }
@@ -220,6 +220,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: .volatile(), appSettings: .volatile(),
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }
@@ -147,6 +147,7 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview {
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: .volatile(), appSettings: .volatile(),
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
}() }()
@@ -203,6 +203,7 @@ struct HomeScreenInviteCell_Previews: PreviewProvider, TestablePreview {
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: .volatile(), appSettings: .volatile(),
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }
@@ -135,6 +135,7 @@ struct HomeScreenKnockedCell_Previews: PreviewProvider, TestablePreview {
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: .volatile(), appSettings: .volatile(),
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }
@@ -124,6 +124,7 @@ struct HomeScreenRecoveryKeyConfirmationBanner_Previews: PreviewProvider, Testab
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: .volatile(), appSettings: .volatile(),
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }
@@ -26,7 +26,7 @@ class BugReportService: NSObject, BugReportServiceProtocol {
rageshakeURL != .disabled rageshakeURL != .disabled
} }
var lastCrashEventID: String? let lastCrashEventIDSubject = CurrentValueSubject<String?, Never>(nil)
init(rageshakeURLPublisher: CurrentValuePublisher<RageshakeConfiguration, Never>, init(rageshakeURLPublisher: CurrentValuePublisher<RageshakeConfiguration, Never>,
applicationID: String, applicationID: String,
@@ -48,10 +48,6 @@ class BugReportService: NSObject, BugReportServiceProtocol {
// MARK: - BugReportServiceProtocol // MARK: - BugReportServiceProtocol
var crashedLastRun: Bool {
SentrySDK.lastRunStatus == .didCrash
}
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
func submitBugReport(_ bugReport: BugReport, func submitBugReport(_ bugReport: BugReport,
progressListener: CurrentValueSubject<Double, Never>) async -> Result<SubmitBugReportResponse, BugReportServiceError> { progressListener: CurrentValueSubject<Double, Never>) async -> Result<SubmitBugReportResponse, BugReportServiceError> {
@@ -81,7 +77,7 @@ class BugReportService: NSObject, BugReportServiceProtocol {
params.append(.init(key: "device_keys", type: .text(value: compactKeys))) params.append(.init(key: "device_keys", type: .text(value: compactKeys)))
} }
if let crashEventID = lastCrashEventID { if let crashEventID = lastCrashEventIDSubject.value {
params.append(MultipartFormData(key: "crash_report", type: .text(value: "<https://sentry.tools.element.io/organizations/element/issues/?project=44&query=\(crashEventID)>"))) params.append(MultipartFormData(key: "crash_report", type: .text(value: "<https://sentry.tools.element.io/organizations/element/issues/?project=44&query=\(crashEventID)>")))
bugReport.githubLabels.append("crash") bugReport.githubLabels.append("crash")
} }
@@ -155,7 +151,7 @@ class BugReportService: NSObject, BugReportServiceProtocol {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let uploadResponse = try decoder.decode(SubmitBugReportResponse.self, from: data) let uploadResponse = try decoder.decode(SubmitBugReportResponse.self, from: data)
lastCrashEventID = nil lastCrashEventIDSubject.send(nil)
MXLog.info("Feedback submitted.") MXLog.info("Feedback submitted.")
@@ -50,9 +50,8 @@ enum BugReportServiceError: LocalizedError {
// sourcery: AutoMockable // sourcery: AutoMockable
protocol BugReportServiceProtocol: AnyObject { protocol BugReportServiceProtocol: AnyObject {
var isEnabled: Bool { get } var isEnabled: Bool { get }
var crashedLastRun: Bool { get }
var lastCrashEventID: String? { get set } var lastCrashEventIDSubject: CurrentValueSubject<String?, Never> { get }
func submitBugReport(_ bugReport: BugReport, func submitBugReport(_ bugReport: BugReport,
progressListener: CurrentValueSubject<Double, Never>) async -> Result<SubmitBugReportResponse, BugReportServiceError> progressListener: CurrentValueSubject<Double, Never>) async -> Result<SubmitBugReportResponse, BugReportServiceError>
@@ -21,7 +21,7 @@ final class BugReportServiceTests {
appSettings.bugReportRageshakeURL.reset() appSettings.bugReportRageshakeURL.reset()
let bugReportServiceMock = BugReportServiceMock() let bugReportServiceMock = BugReportServiceMock()
bugReportServiceMock.crashedLastRun = false bugReportServiceMock.lastCrashEventIDSubject = .init(nil)
bugReportServiceMock.submitBugReportProgressListenerReturnValue = .success(SubmitBugReportResponse(reportURL: "https://www.example.com/123")) bugReportServiceMock.submitBugReportProgressListenerReturnValue = .success(SubmitBugReportResponse(reportURL: "https://www.example.com/123"))
bugReportService = bugReportServiceMock bugReportService = bugReportServiceMock
} }
@@ -32,7 +32,7 @@ final class BugReportServiceTests {
@Test @Test
func initialStateWithMockService() { func initialStateWithMockService() {
#expect(!bugReportService.crashedLastRun) #expect(bugReportService.lastCrashEventIDSubject.value == nil)
} }
@Test @Test
@@ -62,7 +62,7 @@ final class BugReportServiceTests {
session: .mock, session: .mock,
appHooks: AppHooks()) appHooks: AppHooks())
#expect(service.isEnabled) #expect(service.isEnabled)
#expect(!service.crashedLastRun) #expect(bugReportService.lastCrashEventIDSubject.value == nil)
} }
@Test @Test
@@ -74,7 +74,7 @@ final class BugReportServiceTests {
session: .mock, session: .mock,
appHooks: AppHooks()) appHooks: AppHooks())
#expect(!service.isEnabled) #expect(!service.isEnabled)
#expect(!service.crashedLastRun) #expect(bugReportService.lastCrashEventIDSubject.value == nil)
} }
@Test @Test
@@ -432,6 +432,7 @@ final class HomeScreenViewModelTests {
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(), selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: appSettings, appSettings: appSettings,
analyticsService: AnalyticsServiceMock(.init()), analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
notificationManager: notificationManager, notificationManager: notificationManager,
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }