Use the SDK's offline detection everywhere (except for restarting the sync loop). (#4472)

* Use the SDK's offline state to drive the offline indicator.

* Only use network reachability for restarting the sync loop, use the homeserver reachability for requests.

* Add a separate indicator to distinguish when the server is unreachable but the device is online.
This commit is contained in:
Doug
2025-09-08 13:18:02 +01:00
committed by GitHub
parent 20b57d0ad7
commit 90504d89c8
26 changed files with 152 additions and 127 deletions
@@ -278,6 +278,7 @@
"common_sent" = "Sent";
"common_sentence_delimiter" = ". ";
"common_server_not_supported" = "Server not supported";
"common_server_unreachable" = "Server unreachable";
"common_server_url" = "Server URL";
"common_settings" = "Settings";
"common_shared_location" = "Shared location";
@@ -150,7 +150,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
setupStateMachine()
observeApplicationState()
observeNetworkState()
observeAppLockChanges()
registerBackgroundAppRefresh()
@@ -826,24 +825,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
}
private func observeNetworkState() {
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
appMediator.networkMonitor
.reachabilityPublisher
.sink { reachability in
MXLog.info("Reachability changed to \(reachability)")
if reachability == .reachable {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(reachabilityNotificationIdentifier)
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationIdentifier,
title: L10n.commonOffline,
persistent: true))
}
}
.store(in: &cancellables)
}
private func observeAppLockChanges() {
appLockFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -1046,7 +1027,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
switch state {
case .loading:
if self?.appMediator.networkMonitor.reachabilityPublisher.value == .reachable {
if self?.userSession?.clientProxy.homeserverReachabilityPublisher.value == .reachable {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: toastIdentifier, type: .toast(progress: .indeterminate), title: L10n.commonSyncing, persistent: true))
}
case .notLoading:
@@ -769,7 +769,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: flowParameters.userIndicatorController,
notificationSettings: userSession.clientProxy.notificationSettings,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: flowParameters.appMediator,
appSettings: flowParameters.appSettings)
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
@@ -239,6 +239,29 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
.store(in: &cancellables)
let reachabilityNotificationID = "io.element.elementx.reachability.notification"
userSession.clientProxy.homeserverReachabilityPublisher.removeDuplicates()
.combineLatest(flowParameters.appMediator.networkMonitor.reachabilityPublisher.removeDuplicates())
.receive(on: DispatchQueue.main)
.sink { [weak self] homeserverReachability, networkReachability in
MXLog.info("Homeserver reachability: \(homeserverReachability)")
guard let self else { return }
switch (homeserverReachability, networkReachability) {
case (.reachable, _):
flowParameters.userIndicatorController.retractIndicatorWithId(reachabilityNotificationID)
case (.unreachable, .unreachable):
flowParameters.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationID,
title: L10n.commonOffline,
persistent: true))
case (.unreachable, .reachable):
flowParameters.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationID,
title: L10n.commonServerUnreachable,
persistent: true))
}
}
.store(in: &cancellables)
onboardingFlowCoordinator.actions
.sink { [weak self] action in
guard let self else { return }
@@ -507,7 +530,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private func presentSecureBackupLogoutConfirmationScreen() {
let coordinator = SecureBackupLogoutConfirmationScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController,
appMediator: flowParameters.appMediator))
homeserverReachabilityPublisher: userSession.clientProxy.homeserverReachabilityPublisher))
coordinator.actions
.sink { [weak self] action in
+2
View File
@@ -630,6 +630,8 @@ internal enum L10n {
internal static var commonSentenceDelimiter: String { return L10n.tr("Localizable", "common_sentence_delimiter") }
/// Server not supported
internal static var commonServerNotSupported: String { return L10n.tr("Localizable", "common_server_not_supported") }
/// Server unreachable
internal static var commonServerUnreachable: String { return L10n.tr("Localizable", "common_server_unreachable") }
/// Server URL
internal static var commonServerUrl: String { return L10n.tr("Localizable", "common_server_url") }
/// Settings
+6 -6
View File
@@ -48,14 +48,14 @@ extension ClientProxyMock {
roomDirectorySearchProxyReturnValue = configuration.roomDirectorySearchProxy
actionsPublisher = PassthroughSubject<ClientProxyAction, Never>().eraseToAnyPublisher()
loadingStatePublisher = CurrentValuePublisher<ClientProxyLoadingState, Never>(.notLoading)
verificationStatePublisher = CurrentValuePublisher<SessionVerificationState, Never>(.unknown)
loadingStatePublisher = .init(.notLoading)
verificationStatePublisher = .init(.unknown)
homeserverReachabilityPublisher = .init(.reachable)
userAvatarURLPublisher = CurrentValueSubject<URL?, Never>(nil).asCurrentValuePublisher()
userAvatarURLPublisher = .init(nil)
userDisplayNamePublisher = .init("User display name")
userDisplayNamePublisher = CurrentValueSubject<String?, Never>("User display name").asCurrentValuePublisher()
ignoredUsersPublisher = CurrentValueSubject<[String]?, Never>([RoomMemberProxyMock].allMembers.map(\.userID)).asCurrentValuePublisher()
ignoredUsersPublisher = .init([RoomMemberProxyMock].allMembers.map(\.userID))
notificationSettings = configuration.notificationSettings
@@ -2082,6 +2082,11 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
set(value) { underlyingVerificationStatePublisher = value }
}
var underlyingVerificationStatePublisher: CurrentValuePublisher<SessionVerificationState, Never>!
var homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never> {
get { return underlyingHomeserverReachabilityPublisher }
set(value) { underlyingHomeserverReachabilityPublisher = value }
}
var underlyingHomeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>!
var userID: String {
get { return underlyingUserID }
set(value) { underlyingUserID = value }
@@ -26,8 +26,10 @@ class NetworkMonitor: NetworkMonitorProtocol {
pathMonitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
if path.status == .satisfied {
MXLog.info("Network reachability changed to reachable")
self?.reachabilitySubject.send(.reachable)
} else {
MXLog.info("Network reachability changed to unreachable")
self?.reachabilitySubject.send(.unreachable)
}
}
@@ -15,7 +15,6 @@ struct RoomDetailsScreenCoordinatorParameters {
let userIndicatorController: UserIndicatorControllerProtocol
let notificationSettings: NotificationSettingsProxyProtocol
let attributedStringBuilder: AttributedStringBuilderProtocol
let appMediator: AppMediatorProtocol
let appSettings: AppSettings
}
@@ -54,7 +53,6 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
userIndicatorController: parameters.userIndicatorController,
notificationSettingsProxy: parameters.notificationSettings,
attributedStringBuilder: parameters.attributedStringBuilder,
appMediator: parameters.appMediator,
appSettings: parameters.appSettings)
}
@@ -49,7 +49,6 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
userIndicatorController: UserIndicatorControllerProtocol,
notificationSettingsProxy: NotificationSettingsProxyProtocol,
attributedStringBuilder: AttributedStringBuilderProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings) {
self.roomProxy = roomProxy
self.userSession = userSession
@@ -79,7 +78,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported
}
appMediator.networkMonitor.reachabilityPublisher
userSession.clientProxy.homeserverReachabilityPublisher
.filter { $0 == .reachable }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@@ -397,7 +397,6 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
}
@@ -427,7 +426,6 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
}
@@ -466,7 +464,6 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
}
}
@@ -71,7 +71,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
roomProxy: parameters.roomProxy,
initialSelectedPinnedEventID: selectedPinnedEventID,
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
appMediator: parameters.appMediator,
appSettings: parameters.appSettings,
appHooks: parameters.appHooks,
analyticsService: parameters.analytics,
@@ -16,7 +16,6 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -54,14 +53,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomProxy: JoinedRoomProxyProtocol,
initialSelectedPinnedEventID: String?,
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
appHooks: AppHooks,
analyticsService: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
clientProxy = userSession.clientProxy
self.roomProxy = roomProxy
self.appMediator = appMediator
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
@@ -179,7 +176,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
.store(in: &cancellables)
appMediator.networkMonitor.reachabilityPublisher
clientProxy.homeserverReachabilityPublisher
.filter { $0 == .reachable }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@@ -413,13 +410,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
extension RoomScreenViewModel {
static func mock(roomProxyMock: JoinedRoomProxyMock,
clientProxyMock: ClientProxyMock = ClientProxyMock(),
clientProxyMock: ClientProxyMock = ClientProxyMock(.init()),
appHooks: AppHooks = AppHooks()) -> RoomScreenViewModel {
RoomScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appHooks: appHooks,
analyticsService: ServiceLocator.shared.analytics,
@@ -10,7 +10,7 @@ import SwiftUI
struct SecureBackupLogoutConfirmationScreenCoordinatorParameters {
let secureBackupController: SecureBackupControllerProtocol
let appMediator: AppMediatorProtocol
let homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>
}
enum SecureBackupLogoutConfirmationScreenCoordinatorAction {
@@ -30,7 +30,7 @@ final class SecureBackupLogoutConfirmationScreenCoordinator: CoordinatorProtocol
init(parameters: SecureBackupLogoutConfirmationScreenCoordinatorParameters) {
viewModel = SecureBackupLogoutConfirmationScreenViewModel(secureBackupController: parameters.secureBackupController,
appMediator: parameters.appMediator)
homeserverReachabilityPublisher: parameters.homeserverReachabilityPublisher)
}
func start() {
@@ -12,7 +12,7 @@ typealias SecureBackupLogoutConfirmationScreenViewModelType = StateStoreViewMode
class SecureBackupLogoutConfirmationScreenViewModel: SecureBackupLogoutConfirmationScreenViewModelType, SecureBackupLogoutConfirmationScreenViewModelProtocol {
private let secureBackupController: SecureBackupControllerProtocol
private let appMediator: AppMediatorProtocol
private let homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>
private let backupUploadStateSubject: CurrentValueSubject<SecureBackupSteadyState, Never> = .init(.waiting)
@@ -27,13 +27,13 @@ class SecureBackupLogoutConfirmationScreenViewModel: SecureBackupLogoutConfirmat
actionsSubject.eraseToAnyPublisher()
}
init(secureBackupController: SecureBackupControllerProtocol, appMediator: AppMediatorProtocol) {
init(secureBackupController: SecureBackupControllerProtocol, homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>) {
self.secureBackupController = secureBackupController
self.appMediator = appMediator
self.homeserverReachabilityPublisher = homeserverReachabilityPublisher
super.init(initialViewState: .init(mode: .saveRecoveryKey))
backupUploadStateSubject.combineLatest(appMediator.networkMonitor.reachabilityPublisher)
backupUploadStateSubject.combineLatest(homeserverReachabilityPublisher)
.receive(on: DispatchQueue.main)
.sink { [weak self] backupState, reachability in
guard let self, state.mode != .saveRecoveryKey else { return }
@@ -62,7 +62,7 @@ class SecureBackupLogoutConfirmationScreenViewModel: SecureBackupLogoutConfirmat
private func attemptLogout() {
if case .saveRecoveryKey = state.mode {
updateMode(backupState: backupUploadStateSubject.value, reachability: appMediator.networkMonitor.reachabilityPublisher.value)
updateMode(backupState: backupUploadStateSubject.value, reachability: homeserverReachabilityPublisher.value)
keyUploadWaitingTask = Task {
var result = await secureBackupController.waitForKeyBackupUpload(uploadStateSubject: backupUploadStateSubject)
@@ -161,14 +161,9 @@ struct SecureBackupLogoutConfirmationScreen_Previews: PreviewProvider, TestableP
}
let reachability: NetworkMonitorReachability = mode == .offline ? .unreachable : .reachable
let networkMonitor = NetworkMonitorMock()
networkMonitor.underlyingReachabilityPublisher = CurrentValueSubject<NetworkMonitorReachability, Never>(reachability).asCurrentValuePublisher()
let appMediator = AppMediatorMock()
appMediator.underlyingNetworkMonitor = networkMonitor
let viewModel = SecureBackupLogoutConfirmationScreenViewModel(secureBackupController: secureBackupController,
appMediator: appMediator)
homeserverReachabilityPublisher: .init(reachability))
if mode != .saveRecoveryKey {
viewModel.context.send(viewAction: .logout)
@@ -137,6 +137,11 @@ class ClientProxy: ClientProxyProtocol {
verificationStateSubject.asCurrentValuePublisher()
}
private let homeserverReachabilitySubject = CurrentValueSubject<NetworkMonitorReachability, Never>(.reachable)
var homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never> {
homeserverReachabilitySubject.asCurrentValuePublisher()
}
private let timelineMediaVisibilitySubject = CurrentValueSubject<TimelineMediaVisibility, Never>(.always)
var timelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never> {
timelineMediaVisibilitySubject.asCurrentValuePublisher()
@@ -218,10 +223,10 @@ class ClientProxy: ClientProxyProtocol {
})
sendQueueStatusSubject
.combineLatest(networkMonitor.reachabilityPublisher)
.combineLatest(homeserverReachabilityPublisher)
.debounce(for: 1.0, scheduler: DispatchQueue.main)
.sink { enabled, reachability in
MXLog.info("Send queue status changed to enabled: \(enabled), reachability: \(reachability)")
MXLog.info("Send queue status changed to enabled: \(enabled), homeserver reachability: \(reachability)")
if enabled == false, reachability == .reachable {
MXLog.info("Enabling all send queues")
@@ -926,12 +931,11 @@ class ClientProxy: ClientProxyProtocol {
switch state {
case .running, .terminated, .idle:
break
homeserverReachabilitySubject.send(.reachable)
case .offline:
homeserverReachabilitySubject.send(.unreachable)
case .error:
restartSync()
case .offline:
// This needs to be enabled in the client builder first to be actually used
break
}
})
}
@@ -1196,6 +1200,7 @@ private struct ClientProxyServices {
let syncService = try await client
.syncService()
.withCrossProcessLock()
.withOfflineMode()
.withSharePos(enable: true)
.finish()
@@ -80,6 +80,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var verificationStatePublisher: CurrentValuePublisher<SessionVerificationState, Never> { get }
var homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never> { get }
var userID: String { get }
var deviceID: String? { get }
@@ -12,14 +12,14 @@ import UIKit
class MediaProvider: MediaProviderProtocol {
private let mediaLoader: MediaLoaderProtocol
private let imageCache: Kingfisher.ImageCache
private let networkMonitor: NetworkMonitorProtocol?
private let homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>?
init(mediaLoader: MediaLoaderProtocol,
imageCache: Kingfisher.ImageCache,
networkMonitor: NetworkMonitorProtocol?) {
homeserverReachabilityPublisher: CurrentValuePublisher<NetworkMonitorReachability, Never>?) {
self.mediaLoader = mediaLoader
self.imageCache = imageCache
self.networkMonitor = networkMonitor
self.homeserverReachabilityPublisher = homeserverReachabilityPublisher
}
// MARK: Images
@@ -67,8 +67,8 @@ class MediaProvider: MediaProviderProtocol {
}
func loadImageRetryingOnReconnection(_ source: MediaSourceProxy, size: CGSize?) -> Task<UIImage, any Error> {
guard let networkMonitor else {
fatalError("This method shouldn't be invoked without a NetworkMonitor set.")
guard let homeserverReachabilityPublisher else {
fatalError("This method shouldn't be invoked without a homeserver reachability publisher set.")
}
return Task {
@@ -80,7 +80,7 @@ class MediaProvider: MediaProviderProtocol {
throw MediaProviderError.cancelled
}
for await reachability in networkMonitor.reachabilityPublisher.values {
for await reachability in homeserverReachabilityPublisher.values {
guard !Task.isCancelled else {
throw MediaProviderError.cancelled
}
@@ -96,7 +96,7 @@ class UserSessionStore: UserSessionStoreProtocol {
private func buildUserSessionWithClient(_ clientProxy: ClientProxyProtocol) -> UserSessionProtocol {
let mediaProvider = MediaProvider(mediaLoader: clientProxy,
imageCache: .onlyInMemory,
networkMonitor: networkMonitor)
homeserverReachabilityPublisher: clientProxy.homeserverReachabilityPublisher)
let voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider)