From 88a791d02b3d03c1bd45b5441663bc548db48e2f Mon Sep 17 00:00:00 2001 From: Liliana Faustino <101108623+LilianaFaustinoBloco@users.noreply.github.com> Date: Tue, 4 Oct 2022 15:58:22 +0100 Subject: [PATCH] fix: Reflect WiFi hotspot availability on Android 13 (#573) # What kind of change does this PR introduce? Fixes the hotspot state detection on Android 13. # What is the current behavior? This is a temporary fix for [#569 ](https://github.com/relaycorp/relaynet-courier-android/issues/569) but a long-term solution is still needed. # What is the new behavior (if this is a feature change)? On Android 13, we are looking to the gateway addresses request results to infer if hotspot is available. In previous versions of Android, there is no behavior change. --- app/build.gradle | 4 +- .../main/java/tech/relaycorp/courier/App.kt | 13 +- .../java/tech/relaycorp/courier/AppModule.kt | 24 ++++ .../background/ForegroundAppMonitor.kt | 35 +++++ .../background/WifiHotspotStateReceiver.kt | 52 -------- .../background/WifiHotspotStateWatcher.kt | 121 ++++++++++++++++++ .../relaycorp/courier/common/TickerFlow.kt | 12 ++ .../courier/ui/main/MainViewModel.kt | 4 +- .../people/HotspotInstructionsViewModel.kt | 6 +- .../ui/sync/people/PeopleSyncViewModel.kt | 8 +- .../background/WifiHotspotStateWatcherTest.kt | 69 ++++++++++ .../courier/ui/main/MainViewModelTest.kt | 8 +- 12 files changed, 285 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/courier/background/ForegroundAppMonitor.kt delete mode 100644 app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateReceiver.kt create mode 100644 app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateWatcher.kt create mode 100644 app/src/main/java/tech/relaycorp/courier/common/TickerFlow.kt create mode 100644 app/src/test/java/tech/relaycorp/courier/background/WifiHotspotStateWatcherTest.kt diff --git a/app/build.gradle b/app/build.gradle index 3ee5f957..e18bcf9b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,13 +28,13 @@ repositories { } android { - compileSdkVersion 32 + compileSdkVersion 33 ndkVersion '21.3.6528147' defaultConfig { applicationId "tech.relaycorp.courier" minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 33 versionCode 1 versionName project.findProperty("versionName") ?: "0.1" diff --git a/app/src/main/java/tech/relaycorp/courier/App.kt b/app/src/main/java/tech/relaycorp/courier/App.kt index 53d2ab5a..d2ec0ff2 100644 --- a/app/src/main/java/tech/relaycorp/courier/App.kt +++ b/app/src/main/java/tech/relaycorp/courier/App.kt @@ -3,7 +3,8 @@ package tech.relaycorp.courier import android.app.Application import android.os.Build import android.os.StrictMode -import tech.relaycorp.courier.background.WifiHotspotStateReceiver +import tech.relaycorp.courier.background.ForegroundAppMonitor +import tech.relaycorp.courier.background.WifiHotspotStateWatcher import tech.relaycorp.courier.common.Logging import tech.relaycorp.courier.common.di.AppComponent import tech.relaycorp.courier.common.di.DaggerAppComponent @@ -14,7 +15,10 @@ import javax.inject.Inject open class App : Application() { @Inject - lateinit var wifiHotspotStateReceiver: WifiHotspotStateReceiver + lateinit var wifiHotspotStateWatcher: WifiHotspotStateWatcher + + @Inject + lateinit var foregroundAppMonitor: ForegroundAppMonitor open val component: AppComponent by lazy { DaggerAppComponent.builder() @@ -36,12 +40,13 @@ open class App : Application() { component.inject(this) setupLogger() setupStrictMode() - wifiHotspotStateReceiver.register() + registerActivityLifecycleCallbacks(foregroundAppMonitor) + wifiHotspotStateWatcher.start() } override fun onTerminate() { super.onTerminate() - wifiHotspotStateReceiver.unregister() + wifiHotspotStateWatcher.stop() } private fun setupLogger() { diff --git a/app/src/main/java/tech/relaycorp/courier/AppModule.kt b/app/src/main/java/tech/relaycorp/courier/AppModule.kt index d5c9bbd4..9850ebae 100644 --- a/app/src/main/java/tech/relaycorp/courier/AppModule.kt +++ b/app/src/main/java/tech/relaycorp/courier/AppModule.kt @@ -5,6 +5,10 @@ import android.content.res.Resources import android.net.ConnectivityManager import dagger.Module import dagger.Provides +import kotlinx.coroutines.Dispatchers +import tech.relaycorp.cogrpc.server.Networking +import javax.inject.Named +import kotlin.coroutines.CoroutineContext @Module class AppModule( @@ -26,4 +30,24 @@ class AppModule( @Provides fun connectivityManager() = app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + @Provides + fun wifiApState(): WifiApStateAvailability = + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + WifiApStateAvailability.Available + } else { + WifiApStateAvailability.Unavailable + } + + @Provides + @Named("GetGatewayIpAddress") + fun getGatewayIpAddress(): () -> String = Networking::getGatewayIpAddress + + @Provides + @Named("BackgroundCoroutineContext") + fun backgroundCoroutineContext(): CoroutineContext = Dispatchers.IO + + enum class WifiApStateAvailability { + Available, Unavailable + } } diff --git a/app/src/main/java/tech/relaycorp/courier/background/ForegroundAppMonitor.kt b/app/src/main/java/tech/relaycorp/courier/background/ForegroundAppMonitor.kt new file mode 100644 index 00000000..b0c5dc4c --- /dev/null +++ b/app/src/main/java/tech/relaycorp/courier/background/ForegroundAppMonitor.kt @@ -0,0 +1,35 @@ +package tech.relaycorp.courier.background + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ForegroundAppMonitor +@Inject constructor() : Application.ActivityLifecycleCallbacks { + private val activityCountFlow = MutableStateFlow(0) + + fun observe() = activityCountFlow.map { if (it == 0) State.Background else State.Foreground } + + override fun onActivityStarted(activity: Activity) { + activityCountFlow.value++ + } + + override fun onActivityStopped(activity: Activity) { + activityCountFlow.value-- + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + + enum class State { + Foreground, Background + } +} diff --git a/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateReceiver.kt b/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateReceiver.kt deleted file mode 100644 index 75a78406..00000000 --- a/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateReceiver.kt +++ /dev/null @@ -1,52 +0,0 @@ -package tech.relaycorp.courier.background - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.wifi.WifiManager -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import tech.relaycorp.courier.common.BehaviorChannel -import tech.relaycorp.courier.common.Logging.logger -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WifiHotspotStateReceiver -@Inject constructor( - private val context: Context -) : BroadcastReceiver() { - - private val state = BehaviorChannel(WifiHotspotState.Disabled) - fun state() = state.asFlow().distinctUntilChanged() - - fun register() { - context.registerReceiver(this, IntentFilter(WIFI_AP_STATE_CHANGED_ACTION)) - } - - fun unregister() { - context.unregisterReceiver(this) - } - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != WIFI_AP_STATE_CHANGED_ACTION) return - - val stateFlag = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) - logger.info("Wifi State $stateFlag") - state.trySendBlocking( - if (stateFlag == WIFI_AP_STATE_ENABLED) { - WifiHotspotState.Enabled - } else { - WifiHotspotState.Disabled - } - ) - } - - companion object { - // From WifiManager documentation - private const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED" - private const val WIFI_AP_STATE_ENABLED = 13 - } -} diff --git a/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateWatcher.kt b/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateWatcher.kt new file mode 100644 index 00000000..b79ce0ce --- /dev/null +++ b/app/src/main/java/tech/relaycorp/courier/background/WifiHotspotStateWatcher.kt @@ -0,0 +1,121 @@ +package tech.relaycorp.courier.background + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.WifiManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import tech.relaycorp.cogrpc.server.GatewayIPAddressException +import tech.relaycorp.courier.AppModule.WifiApStateAvailability +import tech.relaycorp.courier.common.Logging.logger +import tech.relaycorp.courier.common.tickerFlow +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +@Singleton +class WifiHotspotStateWatcher +@Inject constructor( + private val context: Context, + private val wifiApState: WifiApStateAvailability, + private val foregroundAppMonitor: ForegroundAppMonitor, + @Named("GetGatewayIpAddress") private val getGatewayIpAddress: () -> String, + @Named("BackgroundCoroutineContext") private val backgroundCoroutineContext: CoroutineContext +) { + + private val state = MutableStateFlow(WifiHotspotState.Disabled) + fun state() = state.asStateFlow() + + private var pollingGatewayAddressesJob: Job? = null + + fun start() { + when (wifiApState) { + WifiApStateAvailability.Available -> { + context.registerReceiver( + wifiApStateChangeReceiver, + IntentFilter(WIFI_AP_STATE_CHANGED_ACTION) + ) + } + WifiApStateAvailability.Unavailable -> { + startPollingGatewayAddresses() + } + } + } + + fun stop() { + when (wifiApState) { + WifiApStateAvailability.Available -> { + context.unregisterReceiver(wifiApStateChangeReceiver) + } + WifiApStateAvailability.Unavailable -> { + stopPollingGatewayAddresses() + } + } + } + + private fun startPollingGatewayAddresses() { + pollingGatewayAddressesJob = foregroundAppMonitor.observe() + .flatMapLatest { + if (it == ForegroundAppMonitor.State.Foreground) { + tickerFlow(POLLING_GATEWAY_ADDRESS_INTERVAL) + } else { + emptyFlow() + } + }.map { + try { + getGatewayIpAddress() + WifiHotspotState.Enabled + } catch (exception: GatewayIPAddressException) { + WifiHotspotState.Disabled + } + } + .distinctUntilChanged() + .onEach { + logger.info("Hotspot State $it") + state.value = it + } + .launchIn(CoroutineScope(backgroundCoroutineContext)) + } + + private fun stopPollingGatewayAddresses() { + pollingGatewayAddressesJob?.cancel() + pollingGatewayAddressesJob = null + } + + private val wifiApStateChangeReceiver by lazy { + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != WIFI_AP_STATE_CHANGED_ACTION) return + + val stateFlag = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) + logger.info("Hotspot State $stateFlag") + state.value = + if (stateFlag == WIFI_AP_STATE_ENABLED) { + WifiHotspotState.Enabled + } else { + WifiHotspotState.Disabled + } + } + } + } + + companion object { + // From WifiManager documentation + private const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED" + private const val WIFI_AP_STATE_ENABLED = 13 + + private val POLLING_GATEWAY_ADDRESS_INTERVAL = 2.seconds + } +} diff --git a/app/src/main/java/tech/relaycorp/courier/common/TickerFlow.kt b/app/src/main/java/tech/relaycorp/courier/common/TickerFlow.kt new file mode 100644 index 00000000..98eb3dc2 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/courier/common/TickerFlow.kt @@ -0,0 +1,12 @@ +package tech.relaycorp.courier.common + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlin.time.Duration + +fun tickerFlow(duration: Duration) = flow { + while (true) { + emit(Unit) + delay(duration) + } +} diff --git a/app/src/main/java/tech/relaycorp/courier/ui/main/MainViewModel.kt b/app/src/main/java/tech/relaycorp/courier/ui/main/MainViewModel.kt index 7cc6a948..86d79c4f 100644 --- a/app/src/main/java/tech/relaycorp/courier/ui/main/MainViewModel.kt +++ b/app/src/main/java/tech/relaycorp/courier/ui/main/MainViewModel.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.launch import tech.relaycorp.courier.background.InternetConnection import tech.relaycorp.courier.background.InternetConnectionObserver import tech.relaycorp.courier.background.WifiHotspotState -import tech.relaycorp.courier.background.WifiHotspotStateReceiver +import tech.relaycorp.courier.background.WifiHotspotStateWatcher import tech.relaycorp.courier.common.BehaviorChannel import tech.relaycorp.courier.data.model.StorageSize import tech.relaycorp.courier.data.model.StorageUsage @@ -22,7 +22,7 @@ import javax.inject.Inject class MainViewModel @Inject constructor( internetConnectionObserver: InternetConnectionObserver, - hotspotStateReceiver: WifiHotspotStateReceiver, + hotspotStateReceiver: WifiHotspotStateWatcher, getStorageUsage: GetStorageUsage, observeCCACount: ObserveCCACount, deleteExpiredMessages: DeleteExpiredMessages diff --git a/app/src/main/java/tech/relaycorp/courier/ui/sync/people/HotspotInstructionsViewModel.kt b/app/src/main/java/tech/relaycorp/courier/ui/sync/people/HotspotInstructionsViewModel.kt index 5f1346ae..2261f42e 100644 --- a/app/src/main/java/tech/relaycorp/courier/ui/sync/people/HotspotInstructionsViewModel.kt +++ b/app/src/main/java/tech/relaycorp/courier/ui/sync/people/HotspotInstructionsViewModel.kt @@ -2,17 +2,17 @@ package tech.relaycorp.courier.ui.sync.people import kotlinx.coroutines.flow.map import tech.relaycorp.courier.background.WifiHotspotState -import tech.relaycorp.courier.background.WifiHotspotStateReceiver +import tech.relaycorp.courier.background.WifiHotspotStateWatcher import tech.relaycorp.courier.ui.BaseViewModel import javax.inject.Inject class HotspotInstructionsViewModel @Inject constructor( - private val wifiHotspotStateReceiver: WifiHotspotStateReceiver + private val wifiHotspotStateWatcher: WifiHotspotStateWatcher ) : BaseViewModel() { fun state() = - wifiHotspotStateReceiver + wifiHotspotStateWatcher .state() .map { it.toState() } diff --git a/app/src/main/java/tech/relaycorp/courier/ui/sync/people/PeopleSyncViewModel.kt b/app/src/main/java/tech/relaycorp/courier/ui/sync/people/PeopleSyncViewModel.kt index e871e20d..cae68a8e 100644 --- a/app/src/main/java/tech/relaycorp/courier/ui/sync/people/PeopleSyncViewModel.kt +++ b/app/src/main/java/tech/relaycorp/courier/ui/sync/people/PeopleSyncViewModel.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import tech.relaycorp.courier.background.WifiHotspotState -import tech.relaycorp.courier.background.WifiHotspotStateReceiver +import tech.relaycorp.courier.background.WifiHotspotStateWatcher import tech.relaycorp.courier.common.BehaviorChannel import tech.relaycorp.courier.common.PublishChannel import tech.relaycorp.courier.domain.PrivateSync @@ -21,7 +21,7 @@ import javax.inject.Inject class PeopleSyncViewModel @Inject constructor( private val privateSync: PrivateSync, - wifiHotspotStateReceiver: WifiHotspotStateReceiver + wifiHotspotStateWatcher: WifiHotspotStateWatcher ) : BaseViewModel() { // Inputs @@ -49,7 +49,7 @@ class PeopleSyncViewModel private var hadFirstClient = false init { - wifiHotspotStateReceiver + wifiHotspotStateWatcher .state() .take(1) .onEach { @@ -63,7 +63,7 @@ class PeopleSyncViewModel } .launchIn(scope) - wifiHotspotStateReceiver + wifiHotspotStateWatcher .state() .drop(1) .filter { it == WifiHotspotState.Disabled } diff --git a/app/src/test/java/tech/relaycorp/courier/background/WifiHotspotStateWatcherTest.kt b/app/src/test/java/tech/relaycorp/courier/background/WifiHotspotStateWatcherTest.kt new file mode 100644 index 00000000..aeef84be --- /dev/null +++ b/app/src/test/java/tech/relaycorp/courier/background/WifiHotspotStateWatcherTest.kt @@ -0,0 +1,69 @@ +package tech.relaycorp.courier.background + +import android.content.Context +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.relaycorp.cogrpc.server.GatewayIPAddressException +import tech.relaycorp.courier.AppModule + +class WifiHotspotStateWatcherTest { + private val context = mock() + private val foregroundAppMonitor = mock() + private val getGatewayIpAddress = mock<() -> String>() + private val testCoroutineScope = TestCoroutineScope() + + private val wifiHotspotStateWatcher = WifiHotspotStateWatcher( + context, + AppModule.WifiApStateAvailability.Unavailable, + foregroundAppMonitor, + getGatewayIpAddress, + testCoroutineScope.coroutineContext + ) + + @Test + fun backgroundPollingCheck() = runBlockingTest(testCoroutineScope.coroutineContext) { + whenever(foregroundAppMonitor.observe()).thenReturn(flowOf(ForegroundAppMonitor.State.Background)) + wifiHotspotStateWatcher.start() + + verify(getGatewayIpAddress, never()).invoke() + } + + @Test + fun foregroundPollingCheck() = runBlockingTest(testCoroutineScope.coroutineContext) { + whenever(foregroundAppMonitor.observe()).thenReturn(flowOf(ForegroundAppMonitor.State.Foreground)) + wifiHotspotStateWatcher.start() + + verify(getGatewayIpAddress).invoke() + wifiHotspotStateWatcher.stop() + } + + @Test + fun hotspotDisabledCheck() = runBlockingTest(testCoroutineScope.coroutineContext) { + whenever(foregroundAppMonitor.observe()).thenReturn(flowOf(ForegroundAppMonitor.State.Foreground)) + whenever(getGatewayIpAddress.invoke()).thenAnswer { throw GatewayIPAddressException("") } + wifiHotspotStateWatcher.start() + val hotspotState = wifiHotspotStateWatcher.state().first() + + assertEquals(WifiHotspotState.Disabled, hotspotState) + wifiHotspotStateWatcher.stop() + } + + @Test + fun hotspotEnabledCheck() = runBlockingTest(testCoroutineScope.coroutineContext) { + whenever(foregroundAppMonitor.observe()).thenReturn(flowOf(ForegroundAppMonitor.State.Foreground)) + whenever(getGatewayIpAddress.invoke()).thenReturn("") + wifiHotspotStateWatcher.start() + val hotspotState = wifiHotspotStateWatcher.state().first() + + assertEquals(WifiHotspotState.Enabled, hotspotState) + wifiHotspotStateWatcher.stop() + } +} diff --git a/app/src/test/java/tech/relaycorp/courier/ui/main/MainViewModelTest.kt b/app/src/test/java/tech/relaycorp/courier/ui/main/MainViewModelTest.kt index e9e90436..4eac1eba 100644 --- a/app/src/test/java/tech/relaycorp/courier/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/tech/relaycorp/courier/ui/main/MainViewModelTest.kt @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test import tech.relaycorp.courier.background.InternetConnection import tech.relaycorp.courier.background.InternetConnectionObserver import tech.relaycorp.courier.background.WifiHotspotState -import tech.relaycorp.courier.background.WifiHotspotStateReceiver +import tech.relaycorp.courier.background.WifiHotspotStateWatcher import tech.relaycorp.courier.data.model.StorageSize import tech.relaycorp.courier.data.model.StorageUsage import tech.relaycorp.courier.domain.DeleteExpiredMessages @@ -27,7 +27,7 @@ import tech.relaycorp.courier.test.factory.StoredMessageFactory internal class MainViewModelTest { private val connectionObserver = mock() - private val hotspotStateReceiver = mock() + private val hotspotStateReceiver = mock() private val getStorageUsage = mock() private val observeCCACount = mock() private val deleteExpiredMessages = mock { @@ -37,7 +37,7 @@ internal class MainViewModelTest { @BeforeEach internal fun setUp() { whenever(connectionObserver.observe()).thenReturn(emptyFlow()) - whenever(hotspotStateReceiver.state()).thenReturn(emptyFlow()) + whenever(hotspotStateReceiver.state()).thenReturn(MutableStateFlow(WifiHotspotState.Disabled)) whenever(getStorageUsage.observe()).thenReturn(emptyFlow()) whenever(observeCCACount.observe()).thenReturn(emptyFlow()) } @@ -45,7 +45,7 @@ internal class MainViewModelTest { @Test internal fun syncPeopleState() = runBlockingTest { val connectionStateFlow = MutableStateFlow(InternetConnection.Offline) - whenever(hotspotStateReceiver.state()).thenReturn(flowOf(WifiHotspotState.Disabled)) + whenever(hotspotStateReceiver.state()).thenReturn(MutableStateFlow(WifiHotspotState.Disabled)) whenever(connectionObserver.observe()).thenReturn(connectionStateFlow) val viewModel = buildViewModel()