diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9ff6a44b5..2d6374a46 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + : View { + @ViewBuilder let details: Content var button: (() -> AnyView)? - init(_ details: () -> Text) { + init(_ details: () -> Content) { self.details = details() button = nil } - init(details: Text, button: (() -> AnyView)? = nil) { + init(details: Content, button: (() -> AnyView)? = nil) { self.details = details self.button = button } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 25f3bb508..d42b45599 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -441,6 +441,9 @@ }, "trains" : { "comment" : "trains" + }, + "Unable to connect" : { + }, "Unruly Passenger" : { "comment" : "Possible alert cause" diff --git a/iosApp/iosApp/ViewModels/ErrorBannerViewModel.swift b/iosApp/iosApp/ViewModels/ErrorBannerViewModel.swift index 2ba130fac..f25e491c6 100644 --- a/iosApp/iosApp/ViewModels/ErrorBannerViewModel.swift +++ b/iosApp/iosApp/ViewModels/ErrorBannerViewModel.swift @@ -18,19 +18,28 @@ class ErrorBannerViewModel: ObservableObject { @Published var loadingWhenPredictionsStale: Bool + // option for testing + var skipListeningForStateChanges = false + init( errorRepository: IErrorBannerStateRepository = RepositoryDI().errorBanner, - initialLoadingWhenPredictionsStale: Bool = false + initialLoadingWhenPredictionsStale: Bool = false, + skipListeningForStateChanges: Bool = false ) { self.errorRepository = errorRepository loadingWhenPredictionsStale = initialLoadingWhenPredictionsStale errorState = self.errorRepository.state.value + self.skipListeningForStateChanges = skipListeningForStateChanges } @MainActor func activate() async { - for await errorState in errorRepository.state { - self.errorState = errorState + errorRepository.subscribeToNetworkStatusChanges() + + if !skipListeningForStateChanges { + for await errorState in errorRepository.state { + self.errorState = errorState + } } } diff --git a/iosApp/iosAppTests/Utils/FetchApiTests.swift b/iosApp/iosAppTests/Utils/FetchApiTests.swift index cd4977b08..4776ff885 100644 --- a/iosApp/iosAppTests/Utils/FetchApiTests.swift +++ b/iosApp/iosAppTests/Utils/FetchApiTests.swift @@ -54,7 +54,12 @@ final class FetchApiTests: XCTestCase { onRefreshAfterError: { expRefresh.fulfill() } ) XCTAssertNotNil(errorBannerRepo.state.value) - errorBannerRepo.state.value?.action() + + if let action = errorBannerRepo.state.value?.action { + action() + } else { + XCTFail("data error missing action") + } await fulfillment(of: [expRefresh], timeout: 1) } diff --git a/iosApp/iosAppTests/ViewModels/ErrorBannerViewModelTests.swift b/iosApp/iosAppTests/ViewModels/ErrorBannerViewModelTests.swift new file mode 100644 index 000000000..60eb30919 --- /dev/null +++ b/iosApp/iosAppTests/ViewModels/ErrorBannerViewModelTests.swift @@ -0,0 +1,26 @@ +// +// ErrorBannerViewModelTests.swift +// iosAppTests +// +// Created by Kayla Brady on 10/18/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +@testable import iosApp +import shared +import XCTest + +final class ErrorBannerViewModelTests: XCTestCase { + func testActivateSubscribesToNetworkChanges() async { + let onSubscribeExp = XCTestExpectation(description: "onSubscribe called") + let repo = MockErrorBannerStateRepository(state: nil, onSubscribeToNetworkChanges: { onSubscribeExp.fulfill() }) + let errorVM = ErrorBannerViewModel( + errorRepository: repo, + initialLoadingWhenPredictionsStale: false, + skipListeningForStateChanges: true + ) + await errorVM.activate() + wait(for: [onSubscribeExp], timeout: 1) + } +} diff --git a/iosApp/iosAppTests/Views/ErrorBannerTests.swift b/iosApp/iosAppTests/Views/ErrorBannerTests.swift index fd4c1ebb2..2b8176623 100644 --- a/iosApp/iosAppTests/Views/ErrorBannerTests.swift +++ b/iosApp/iosAppTests/Views/ErrorBannerTests.swift @@ -49,6 +49,14 @@ final class ErrorBannerTests: XCTestCase { wait(for: [callsAction], timeout: 1) } + @MainActor func testWhenNetworkError() throws { + let sut = ErrorBanner(.init( + errorRepository: MockErrorBannerStateRepository(state: .NetworkError()), + initialLoadingWhenPredictionsStale: true + )) + XCTAssertNotNil(try sut.inspect().find(text: "Unable to connect")) + } + @MainActor func testLoadingWhenPredictionsStale() throws { let sut = ErrorBanner(.init( errorRepository: MockErrorBannerStateRepository(state: .StalePredictions( diff --git a/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt b/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt index 603327f5d..9a498d9e5 100644 --- a/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt +++ b/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt @@ -1,5 +1,7 @@ package com.mbta.tid.mbta_app +import com.mbta.tid.mbta_app.network.INetworkConnectivityMonitor +import com.mbta.tid.mbta_app.network.NetworkConnectivityMonitor import com.mbta.tid.mbta_app.utils.AndroidSystemPaths import com.mbta.tid.mbta_app.utils.SystemPaths import org.koin.dsl.module @@ -7,6 +9,7 @@ import org.koin.dsl.module fun platformModule() = module { includes( module { single { createDataStore(get()) } }, - module { single { AndroidSystemPaths(get()) } } + module { single { AndroidSystemPaths(get()) } }, + module { single { NetworkConnectivityMonitor(get()) } } ) } diff --git a/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt b/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt new file mode 100644 index 000000000..65d4d8ebe --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt @@ -0,0 +1,33 @@ +package com.mbta.tid.mbta_app.network + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.CONNECTIVITY_SERVICE +import android.net.ConnectivityManager +import android.net.Network + +class NetworkConnectivityMonitor(context: Context) : INetworkConnectivityMonitor { + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private val connectivityManager = + context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + + @SuppressLint("MissingPermission") + // Permission is included in AndroidManifest.xml + override fun registerListener(onNetworkAvailable: () -> Unit, onNetworkLost: () -> Unit) { + networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + onNetworkAvailable() + } + + override fun onUnavailable() { + onNetworkLost() + } + + override fun onLost(network: Network) { + onNetworkLost() + } + } + networkCallback?.let { connectivityManager.registerDefaultNetworkCallback(it) } + } +} diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/dependencyInjection/RepositoryDI.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/dependencyInjection/RepositoryDI.kt index ebfc85777..5f96a0cf5 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/dependencyInjection/RepositoryDI.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/dependencyInjection/RepositoryDI.kt @@ -93,6 +93,7 @@ class RepositoryDI : IRepositories, KoinComponent { class RealRepositories : IRepositories { // initialize repositories with platform-specific dependencies as null. // instantiate the real repositories in makeNativeModule + override val alerts = null override val appCheck = null override val config = ConfigRepository() diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ErrorBannerState.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ErrorBannerState.kt index 3f2176c75..f39c9c6c5 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ErrorBannerState.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ErrorBannerState.kt @@ -5,7 +5,7 @@ import kotlinx.datetime.Instant sealed class ErrorBannerState { /** What to do when the button in the error banner is pressed */ - abstract val action: () -> Unit + abstract val action: (() -> Unit)? data class StalePredictions(val lastUpdated: Instant, override val action: () -> Unit) : ErrorBannerState() { @@ -13,4 +13,6 @@ sealed class ErrorBannerState { } data class DataError(override val action: () -> Unit) : ErrorBannerState() + + data class NetworkError(override val action: (() -> Unit)?) : ErrorBannerState() } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt new file mode 100644 index 000000000..152a61196 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt @@ -0,0 +1,6 @@ +package com.mbta.tid.mbta_app.network + +/** Observe changes in the device's network connectivity. */ +interface INetworkConnectivityMonitor { + fun registerListener(onNetworkAvailable: () -> Unit, onNetworkLost: () -> Unit) +} diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt index a251505da..9e2a277bf 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepository.kt @@ -1,24 +1,47 @@ package com.mbta.tid.mbta_app.repositories import com.mbta.tid.mbta_app.model.ErrorBannerState +import com.mbta.tid.mbta_app.network.INetworkConnectivityMonitor import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +sealed class NetworkStatus { + data object Connected : NetworkStatus() + + data object Disconnected : NetworkStatus() +} + +abstract class IErrorBannerStateRepository(initialState: ErrorBannerState? = null) : KoinComponent { + + private val networkConnectivityMonitor: INetworkConnectivityMonitor by inject() + + /* + Registers platform-specific observer of network status changes. + */ + open fun subscribeToNetworkStatusChanges() { + this.networkConnectivityMonitor.registerListener( + onNetworkAvailable = { setNetworkStatus(NetworkStatus.Connected) }, + onNetworkLost = { setNetworkStatus(NetworkStatus.Disconnected) } + ) + } -abstract class IErrorBannerStateRepository -protected constructor(initialState: ErrorBannerState? = null) { protected val flow = MutableStateFlow(initialState) val state = flow.asStateFlow() + private var networkStatus: NetworkStatus? = null + private var predictionsStale: ErrorBannerState.StalePredictions? = null private val dataErrors = mutableMapOf() protected open fun updateState() { flow.value = when { + networkStatus == NetworkStatus.Disconnected -> ErrorBannerState.NetworkError(null) dataErrors.isNotEmpty() -> // encapsulate all the different error actions within one error ErrorBannerState.DataError { dataErrors.values.forEach { it.action() } } @@ -41,6 +64,11 @@ protected constructor(initialState: ErrorBannerState? = null) { updateState() } + private fun setNetworkStatus(newStatus: NetworkStatus) { + networkStatus = newStatus + updateState() + } + fun setDataError(key: String, action: () -> Unit) { dataErrors[key] = ErrorBannerState.DataError(action) updateState() @@ -60,8 +88,15 @@ protected constructor(initialState: ErrorBannerState? = null) { class ErrorBannerStateRepository : IErrorBannerStateRepository(), KoinComponent -class MockErrorBannerStateRepository(state: ErrorBannerState? = null) : - IErrorBannerStateRepository(state) { +class MockErrorBannerStateRepository( + state: ErrorBannerState? = null, + onSubscribeToNetworkChanges: (() -> Unit)? = null +) : IErrorBannerStateRepository(state) { + private val onSubscribeToNetworkChanges = onSubscribeToNetworkChanges val mutableFlow get() = flow + + override fun subscribeToNetworkStatusChanges() { + onSubscribeToNetworkChanges?.invoke() + } } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt index 84c286daf..d87bc538a 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/repositories/ErrorBannerStateRepositoryTest.kt @@ -1,6 +1,12 @@ package com.mbta.tid.mbta_app.repositories import com.mbta.tid.mbta_app.model.ErrorBannerState +import com.mbta.tid.mbta_app.network.INetworkConnectivityMonitor +import dev.mokkery.MockMode +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -13,8 +19,13 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module class ErrorBannerStateRepositoryTest { + @AfterTest fun `stop koin`() = run { stopKoin() } + @Test fun `initial state is null`() = runBlocking { val repo = ErrorBannerStateRepository() @@ -112,4 +123,18 @@ class ErrorBannerStateRepositoryTest { assertNull(channel.receive()) } + + @Test + fun `subscribe to connectivity changes`() { + + val mockNetworkMonitor = mock(MockMode.autofill) + + startKoin { modules(module { single { mockNetworkMonitor } }) } + + val repo = ErrorBannerStateRepository() + + repo.subscribeToNetworkStatusChanges() + + verify { mockNetworkMonitor.registerListener(any(), any()) } + } } diff --git a/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt b/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt index 659d2f3cd..6bd381b75 100644 --- a/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt +++ b/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/PlatformModule.kt @@ -1,5 +1,7 @@ package com.mbta.tid.mbta_app +import com.mbta.tid.mbta_app.network.INetworkConnectivityMonitor +import com.mbta.tid.mbta_app.network.NetworkConnectivityMonitor import com.mbta.tid.mbta_app.utils.IOSSystemPaths import com.mbta.tid.mbta_app.utils.SystemPaths import org.koin.dsl.module @@ -7,6 +9,7 @@ import org.koin.dsl.module fun platformModule() = module { includes( module { single { createDataStore() } }, - module { single { IOSSystemPaths() } } + module { single { IOSSystemPaths() } }, + module { single { NetworkConnectivityMonitor() } } ) } diff --git a/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt b/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt new file mode 100644 index 000000000..8ac906287 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/mbta/tid/mbta_app/network/NetworkConnectivityMonitor.kt @@ -0,0 +1,23 @@ +package com.mbta.tid.mbta_app.network + +import platform.Network.* +import platform.darwin.dispatch_get_main_queue + +class NetworkConnectivityMonitor : INetworkConnectivityMonitor { + private val monitor = nw_path_monitor_create() + + override fun registerListener(onNetworkAvailable: () -> Unit, onNetworkLost: () -> Unit) { + nw_path_monitor_set_update_handler(monitor) { path -> + val pathStatus = nw_path_get_status(path) + + if (pathStatus == nw_path_status_satisfied) { + onNetworkAvailable() + } else { + onNetworkLost() + } + } + + nw_path_monitor_set_queue(monitor, dispatch_get_main_queue()) + nw_path_monitor_start(monitor) + } +}