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)
+ }
+}