Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/C/Threema/Threema/Utilities/   (Social Medium ©)  Datei vom 26.9.2026 mit Größe 9 kB image not shown  

Quelle  SentryCrashConsentPresenter.swift   Sprache: JAVA

 

import CocoaLumberjackSwift
import Sentry
import ThreemaFramework
import ThreemaMacros
import UIKit

/// Asks the user whether a crash report may be sent, once per report.
///
/// The event is enqueued and the alert is shown once the app is in a state where the user can
/// actually see and answer it. Until then the report stays queued and presentation is retried, so an
/// interruption — an inactive app, the passcode lock, a UI that isn't up yet — postpones the question
/// instead of dropping the report.
///
/// - Note: Reports queued here only live as long as the process does. If the app is killed before the
///   user answers, the report is gone.
@MainActor
final class SentryCrashConsentPresenter {

    static let shared = SentryCrashConsentPresenter()

    /// The user's answer for a single crash report.
    struct Decision {
        let send: Bool
        /// What the user typed into the comment field, `nil` if empty or not sent.
        let comment: String?
    }

    // MARK: - Private types

    /// A crash report waiting for the user's decision.
    private struct PendingReport {
        let event: Sentry.Event
        let eventID: String
        let decisionHandler: @MainActor (Decision) -> Void
    }

    // MARK: - Private properties

    /// How long to wait before checking again whether the alert can be presented.
    private static let retryDelay = Duration.seconds(2)

    /// Stop retrying after roughly five minutes. Reports stay queued and the next
    /// `didBecomeActiveNotification` starts a new round.
    private static let maxRetries = 150

    private var pendingReports: [PendingReport] = []
    private var isPresenting = false

    private var retryScheduled = false
    private var retryCount = 0

    /// Notifications after which a queued report is worth asking about again, each resetting the retry
    /// budget. Releasing the passcode lock is not an app state change, so `didBecomeActiveNotification`
    /// does not cover it.
    private static let retryTriggers: [Notification.Name] = [
        UIApplication.didBecomeActiveNotification,
        .appUnlocked,
    ]

    private var retryTriggerObservers: [NSObjectProtocol] = []

    /// Attempts to present the consent alert now. Returns `false` when the app is in no state to show
    /// it, in which case the caller retries. On the user's answer the completion runs exactly once.
    /// Injected so the queue logic can be tested without UIKit.
    private let present: @MainActor (@escaping @MainActor (Decision) -> Void) -> Bool

    /// Runs `retry` after ``retryDelay``. Injected so tests can drive retries without waiting.
    private let scheduleRetry: @MainActor (@escaping @MainActor () -> Void) -> Void

    // MARK: - Lifecycle

    private init() {
        present = SentryCrashConsentPresenter.presentAlert
        scheduleRetry = SentryCrashConsentPresenter.scheduleRetryAfterDelay
        registerRetryTriggerObservers()
    }

    #if DEBUG
        /// Test seam: inject the presentation and retry scheduling.
        init(
            present: @escaping @MainActor (@escaping @MainActor (Decision) -> Void) -> Bool,
            scheduleRetry: @escaping @MainActor (@escaping @MainActor () -> Void) -> Void
        ) {
            self.present = present
            self.scheduleRetry = scheduleRetry
            registerRetryTriggerObservers()
        }
    #endif

    deinit {
        for observer in retryTriggerObservers {
            NotificationCenter.default.removeObserver(observer)
        }
    }

    private func registerRetryTriggerObservers() {
        retryTriggerObservers = Self.retryTriggers.map { name in
            NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
                // `queue: .main` guarantees we are on the main thread here.
                MainActor.assumeIsolated {
                    guard let self else {
                        return
                    }
                    self.retryCount = 0
                    self.presentNextIfPossible()
                }
            }
        }
    }

    // MARK: - Public interface

    /// Queues a crash report and asks the user about it as soon as the app can show an alert.
    ///
    /// - Parameters:
    ///   - event: The already sanitized event. It is not uploaded unless `decisionHandler` is called
    ///            with a `Decision` that has `send` set.
    ///   - eventID: Identifier of `event`, used to remove it from the queue once answered. A report
    ///              that is already queued under this ID is ignored, and its `decisionHandler` is
    ///              never called: one answer cannot be delivered to two handlers.
    ///   - decisionHandler: Called at most once, with the user's answer.
    func enqueue(
        _ event: Sentry.Event,
        eventID: String,
        decisionHandler: @escaping @MainActor (Decision) -> Void
    ) {
        guard !pendingReports.contains(where: { $0.eventID == eventID }) else {
            DDLogNotice("[Sentry] Crash report \(eventID) is already queued for a decision.")
            return
        }

        pendingReports.append(PendingReport(
            event: event,
            eventID: eventID,
            decisionHandler: decisionHandler
        ))
        presentNextIfPossible()
    }

    // MARK: - Presentation

    private func presentNextIfPossible() {
        guard !isPresenting, let report = pendingReports.first else {
            return
        }

        isPresenting = true
        let presented = present { [weak self] decision in
            self?.finish(report, with: decision)
        }

        guard presented else {
            isPresenting = false
            DDLogNotice("[Sentry] App cannot show the crash report alert yet, will retry.")
            scheduleRetryIfAllowed()
            return
        }

        // `retryScheduled` is deliberately not cleared here: it tracks whether a retry closure is in
        // flight, and one scheduled before this attempt still is. Clearing it would let the next failure
        // schedule a second, parallel retry chain, halving the effective retry window.
        retryCount = 0
    }

    private func finish(_ report: PendingReport, with decision: Decision) {
        pendingReports.removeAll { $0.eventID == report.eventID }

        isPresenting = false

        report.decisionHandler(decision)

        presentNextIfPossible()
    }

    private func scheduleRetryIfAllowed() {
        guard !retryScheduled else {
            return
        }

        guard retryCount < Self.maxRetries else {
            DDLogWarn(
                "[Sentry] Giving up on presenting the crash report alert for now. \(pendingReports.count) report(s) stay queued until the app becomes active again."
            )
            return
        }

        retryCount += 1
        retryScheduled = true
        scheduleRetry { [weak self] in
            guard let self else {
                return
            }
            retryScheduled = false
            presentNextIfPossible()
        }
    }

    // MARK: - Production presentation

    /// Presents the consent alert on the current top view controller, if the app can show one.
    private static func presentAlert(_ completion: @escaping @MainActor (Decision) -> Void) -> Bool {
        guard let presentingViewController = presentableViewController() else {
            return false
        }

        presentingViewController.present(alert(completion: completion), animated: true)
        return true
    }

    /// Runs `retry` on the main actor after ``retryDelay``.
    private static func scheduleRetryAfterDelay(_ retry: @escaping @MainActor () -> Void) {
        Task { @MainActor in
            try? await Task.sleep(for: retryDelay)
            retry()
        }
    }

    private static func alert(completion: @escaping @MainActor (Decision) -> Void) -> UIAlertController {
        let confirm = UIAlertController(
            title: String.localizedStringWithFormat(
                #localize("sentry_crash_send_title"),
                TargetManager.appName
            ),
            message: #localize("sentry_crash_send_description"),
            preferredStyle: .alert
        )

        confirm.addTextField { textField in
            textField.placeholder = #localize("sentry_crash_comment_placeholder")
        }

        // `confirm` is captured weakly: the alert owns its actions, which own these handlers.
        confirm.addAction(UIAlertAction(
            title: #localize("sentry_crash_send_yes"),
            style: .default,
            handler: { [weak confirm] _ in
                let comment = confirm?.textFields?.first?.text
                completion(Decision(send: true, comment: (comment?.isEmpty ?? true) ? nil : comment))
            }
        ))

        confirm.addAction(UIAlertAction(
            title: #localize("sentry_crash_send_no"),
            style: .cancel,
            handler: { _ in
                completion(Decision(send: false, comment: nil))
            }
        ))

        return confirm
    }

    /// The view controller to present on, or `nil` while the app is in no state to show an alert the
    /// user could actually see and answer.
    ///
    /// The passcode lock lives in its own window, so an alert presented on the app window while the
    /// app is locked would be invisible but count as answered. Onboarding and license entry are
    /// excluded for the same reason.
    private static func presentableViewController() -> UIViewController? {
        guard UIApplication.shared.applicationState == .active,
              !SharedAppProvider.isAppLocked,
              !SharedAppProvider.isPresentingEnterLicense,
              !SharedAppProvider.isPresentingKeyGeneration,
              let topViewController = SharedAppProvider.currentTopViewController,
              topViewController.view.window != nil,
              !topViewController.isBeingPresented,
              !topViewController.isBeingDismissed,
              !(topViewController is UIAlertController)
        else {
            return nil
        }

        return topViewController
    }
}

Messung
CHZG
91969995

¤ Dauer der Verarbeitung: 0.1 Sekunden  (vorverarbeitet am  2026-11-04) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

PVS Prover

Isabelle Prover


Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.