From 162266fbb7e583d7123690906053d7a40a4cba12 Mon Sep 17 00:00:00 2001 From: Filip Stanis Date: Mon, 20 Sep 2021 17:20:47 +0100 Subject: [PATCH] Replace LiveData and callbacks with flows (#2) --- app/build.gradle | 4 +- .../FakeConnectivityStatusListener.kt | 18 +- .../phonestate/FakeTelephonyStatusListener.kt | 21 +- .../networks/NetworksFragmentTest.kt | 13 +- .../phonestate/PhoneStateFragmentTest.kt | 11 +- app/src/main/AndroidManifest.xml | 5 +- .../java/com/devrel/android/minwos/App.kt | 21 +- .../data/networks/ConnectivityStatus.kt | 4 + .../networks/ConnectivityStatusListener.kt | 252 +++++++------- .../minwos/data/phonestate/TelephonyStatus.kt | 2 + .../phonestate/TelephonyStatusListener.kt | 318 ++++++++++-------- .../android/minwos/data/util/RefreshFlow.kt | 54 +++ .../minwos/data/util/VibrationHelper.kt | 29 ++ .../minwos/service/ForegroundStatusService.kt | 9 +- .../devrel/android/minwos/ui/MainActivity.kt | 2 - .../ui/fragments/networks/NetworksFragment.kt | 18 +- .../fragments/networks/NetworksViewModel.kt | 21 +- .../phonestate/PhoneStateFragment.kt | 26 +- .../phonestate/PhoneStateViewModel.kt | 38 ++- .../com/devrel/android/minwos/TestUtil.kt | 39 +++ .../data/phonestate/TelephonyStatusTest.kt | 40 ++- .../minwos/data/util/RefreshFlowTest.kt | 62 ++++ .../networks/NetworksViewModelTest.kt | 43 ++- .../phonestate/PhoneStateViewModelTest.kt | 59 +++- 24 files changed, 713 insertions(+), 396 deletions(-) create mode 100644 app/src/main/java/com/devrel/android/minwos/data/util/RefreshFlow.kt create mode 100644 app/src/main/java/com/devrel/android/minwos/data/util/VibrationHelper.kt create mode 100644 app/src/test/java/com/devrel/android/minwos/TestUtil.kt create mode 100644 app/src/test/java/com/devrel/android/minwos/data/util/RefreshFlowTest.kt diff --git a/app/build.gradle b/app/build.gradle index d9b3cca..aa8cad3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,7 +26,7 @@ android { applicationId "com.devrel.android.minwos" minSdkVersion 26 targetSdkVersion 30 - versionCode 301 + versionCode 302 versionName "3.0" testInstrumentationRunner 'com.devrel.android.minwos.TestRunner' @@ -61,6 +61,7 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.3.3' implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01' implementation 'androidx.lifecycle:lifecycle-service:2.3.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' @@ -74,6 +75,7 @@ dependencies { testImplementation 'androidx.arch.core:core-testing:2.1.0' testImplementation 'com.google.truth:truth:1.0.1' testImplementation 'junit:junit:4.13.2' + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_version" testImplementation 'org.mockito:mockito-core:3.3.3' androidTestImplementation 'androidx.test:rules:1.3.0' diff --git a/app/src/androidTest/java/com/devrel/android/minwos/data/networks/FakeConnectivityStatusListener.kt b/app/src/androidTest/java/com/devrel/android/minwos/data/networks/FakeConnectivityStatusListener.kt index d601c8b..b7ce7c5 100644 --- a/app/src/androidTest/java/com/devrel/android/minwos/data/networks/FakeConnectivityStatusListener.kt +++ b/app/src/androidTest/java/com/devrel/android/minwos/data/networks/FakeConnectivityStatusListener.kt @@ -17,22 +17,14 @@ package com.devrel.android.minwos.data.networks import android.net.LinkProperties +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking class FakeConnectivityStatusListener : ConnectivityStatusListener { - var connectivityCallback: ((ConnectivityStatus) -> Unit)? = null + override val flow = MutableSharedFlow() - override fun setCallback(callback: (ConnectivityStatus) -> Unit) { - connectivityCallback = callback - } - - override fun clearCallback() { - connectivityCallback = null - } - - override fun startListening() {} - override fun stopListening() {} - override fun refresh() { - connectivityCallback?.invoke( + override fun refresh() = runBlocking { + flow.emit( ConnectivityStatus( null, listOf( diff --git a/app/src/androidTest/java/com/devrel/android/minwos/data/phonestate/FakeTelephonyStatusListener.kt b/app/src/androidTest/java/com/devrel/android/minwos/data/phonestate/FakeTelephonyStatusListener.kt index bd52698..5c49221 100644 --- a/app/src/androidTest/java/com/devrel/android/minwos/data/phonestate/FakeTelephonyStatusListener.kt +++ b/app/src/androidTest/java/com/devrel/android/minwos/data/phonestate/FakeTelephonyStatusListener.kt @@ -16,21 +16,14 @@ package com.devrel.android.minwos.data.phonestate -class FakeTelephonyStatusListener : TelephonyStatusListener { - var telephonyCallback: ((TelephonyStatus) -> Unit)? = null - - override fun setCallback(callback: (TelephonyStatus) -> Unit) { - telephonyCallback = callback - } +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking - override fun clearCallback() { - telephonyCallback = null - } +class FakeTelephonyStatusListener : TelephonyStatusListener { + override val flow = MutableSharedFlow() - override fun startListening() {} - override fun stopListening() {} - override fun refresh() { - telephonyCallback?.invoke( + override fun refresh() = runBlocking { + flow.emit( TelephonyStatus( listOf( TelephonyStatus.TelephonyData(SubscriptionInfo(99, 0), SimInfo("", "refresh")) @@ -38,4 +31,6 @@ class FakeTelephonyStatusListener : TelephonyStatusListener { ) ) } + + override fun recheckPermissions() {} } diff --git a/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragmentTest.kt b/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragmentTest.kt index 812cb78..ac92d67 100644 --- a/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragmentTest.kt +++ b/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragmentTest.kt @@ -42,6 +42,8 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking import org.hamcrest.Matchers.allOf import org.junit.Before import org.junit.Rule @@ -67,8 +69,8 @@ class NetworksFragmentTest { @BindValue val telephonyStatusListener: TelephonyStatusListener = FakeTelephonyStatusListener() - private val connectivityCallback get() = - (connectivityStatusListener as FakeConnectivityStatusListener).connectivityCallback + private val sharedFlow + get() = connectivityStatusListener.flow as MutableSharedFlow @Before fun setUp() { @@ -81,13 +83,13 @@ class NetworksFragmentTest { } @Test - fun displaysNetworks() { + fun displaysNetworks() = runBlocking { val network1 = NetworkData(0, linkProperties = LinkProperties().apply { interfaceName = "test0" }) val network2 = NetworkData(1, linkProperties = LinkProperties().apply { interfaceName = "test1" }) onView(withId(R.id.networksRecyclerView)).check(matches(hasChildCount(0))) - connectivityCallback?.invoke(ConnectivityStatus(null, listOf(network1, network2))) + sharedFlow.emit(ConnectivityStatus(null, listOf(network1, network2))) onView(withId(R.id.networksRecyclerView)).check( matches( allOf( @@ -97,7 +99,7 @@ class NetworksFragmentTest { ) ) ) - connectivityCallback?.invoke(ConnectivityStatus(network2, listOf(network1, network2))) + sharedFlow.emit(ConnectivityStatus(network2, listOf(network1, network2))) onView(withId(R.id.networksRecyclerView)).check( matches( allOf( @@ -107,6 +109,7 @@ class NetworksFragmentTest { ) ) ) + Unit } @Test diff --git a/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragmentTest.kt b/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragmentTest.kt index 88ccc7b..c9ef79d 100644 --- a/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragmentTest.kt +++ b/app/src/androidTest/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragmentTest.kt @@ -44,6 +44,8 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking import org.hamcrest.Matchers.allOf import org.junit.Before import org.junit.Rule @@ -70,8 +72,8 @@ class PhoneStateFragmentTest { val telephonyStatusListener: TelephonyStatusListener = FakeTelephonyStatusListener() private val baseTelephonyData = TelephonyData(SubscriptionInfo(1, 0), SimInfo("", "")) - private val telephonyCallback get() = - (telephonyStatusListener as FakeTelephonyStatusListener).telephonyCallback + private val sharedFlow + get() = telephonyStatusListener.flow as MutableSharedFlow @Before fun setUp() { @@ -84,11 +86,11 @@ class PhoneStateFragmentTest { } @Test - fun displaysPhoneState() { + fun displaysPhoneState() = runBlocking { val data1 = baseTelephonyData.copy(networkType = TelephonyManager.NETWORK_TYPE_EDGE) val data2 = baseTelephonyData.copy(networkType = TelephonyManager.NETWORK_TYPE_LTE) onView(withId(R.id.telephonyRecyclerView)).check(matches(hasChildCount(0))) - telephonyCallback?.invoke(TelephonyStatus(listOf(data1, data2))) + sharedFlow.emit(TelephonyStatus(listOf(data1, data2))) onView(withId(R.id.telephonyRecyclerView)).check( matches( allOf( @@ -98,6 +100,7 @@ class PhoneStateFragmentTest { ) ) ) + Unit } @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c1f7c6..51e95ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/devrel/android/minwos/App.kt b/app/src/main/java/com/devrel/android/minwos/App.kt index 1c5a0ea..c4f6d40 100644 --- a/app/src/main/java/com/devrel/android/minwos/App.kt +++ b/app/src/main/java/com/devrel/android/minwos/App.kt @@ -20,6 +20,7 @@ import android.app.Application import android.app.NotificationManager import android.content.Context import android.net.ConnectivityManager +import android.os.Vibrator import android.telephony.SubscriptionManager import android.telephony.TelephonyManager import dagger.Module @@ -29,6 +30,10 @@ import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @HiltAndroidApp class App : Application() @@ -37,22 +42,30 @@ class App : Application() @InstallIn(SingletonComponent::class) object AppModule { @Provides - @Singleton fun provideConnectivityManager(@ApplicationContext context: Context) = context.getSystemService(ConnectivityManager::class.java)!! @Provides - @Singleton fun provideTelephonyManager(@ApplicationContext context: Context) = context.getSystemService(TelephonyManager::class.java)!! @Provides - @Singleton fun provideNotificationManager(@ApplicationContext context: Context) = context.getSystemService(NotificationManager::class.java)!! @Provides - @Singleton fun provideSubscriptionManager(@ApplicationContext context: Context) = context.getSystemService(SubscriptionManager::class.java)!! + + @Provides + fun provideVibrator(@ApplicationContext context: Context) = + context.getSystemService(Vibrator::class.java)!! + + @Provides + fun provideDispatcher() = Dispatchers.Default + + @Provides + @Singleton + fun provideCoroutineScope(coroutineDispatcher: CoroutineDispatcher): CoroutineScope = + CoroutineScope(SupervisorJob() + coroutineDispatcher) } diff --git a/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatus.kt b/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatus.kt index fc891e8..f123ce8 100644 --- a/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatus.kt +++ b/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatus.kt @@ -27,6 +27,10 @@ data class ConnectivityStatus( // ensure default network is first, if set val networks = defaultNetwork?.let { listOf(it).union(allNetworks).toList() } ?: allNetworks + companion object { + val EMPTY = ConnectivityStatus(null, listOf()) + } + data class NetworkData( val id: Int, val networkCapabilities: NetworkCapabilities? = null, diff --git a/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatusListener.kt b/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatusListener.kt index b412359..72f094b 100644 --- a/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatusListener.kt +++ b/app/src/main/java/com/devrel/android/minwos/data/networks/ConnectivityStatusListener.kt @@ -21,153 +21,167 @@ import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import com.devrel.android.minwos.data.networks.ConnectivityStatus.NetworkData +import com.devrel.android.minwos.data.util.RefreshFlow +import com.devrel.android.minwos.data.util.VibrationHelper import javax.inject.Inject - -interface ConnectivityStatusListener : DefaultLifecycleObserver { - fun startListening() - fun stopListening() +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +interface ConnectivityStatusListener { + val flow: SharedFlow fun refresh() - fun setCallback(callback: (ConnectivityStatus) -> Unit) - fun clearCallback() - - override fun onStart(owner: LifecycleOwner) = startListening() - override fun onStop(owner: LifecycleOwner) = stopListening() } +@ExperimentalCoroutinesApi class ConnectivityStatusListenerImpl @Inject constructor( - private val connectivityManager: ConnectivityManager + private val coroutineScope: CoroutineScope, + private val connectivityManager: ConnectivityManager, + private val vibrationHelper: VibrationHelper ) : ConnectivityStatusListener { - private var defaultNetwork: Network? = null - private val networkMap = mutableMapOf() + private val refreshFlow = RefreshFlow() - private val networks - get() = ConnectivityStatus(networkMap[defaultNetwork], networkMap.values.toList()) + override fun refresh() { + refreshFlow.emit() + } - private var isListening = false - private var callback: ((ConnectivityStatus) -> Unit)? = null + override val flow = callbackFlow { + val manager = FlowManager(this) + manager.registerListeners() + awaitClose { manager.unregisterListeners() } + }.shareIn(coroutineScope, WhileSubscribed(5000L), 1) - override fun startListening() { - if (isListening) { - return - } - reset() - connectivityManager.registerNetworkCallback( - NetworkRequest.Builder() - // these capability filters are added by default - .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) - .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN).build(), - networkCallback - ) - connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback) - isListening = true - } + private inner class FlowManager( + private val sendChannel: SendChannel + ) { + var refreshJob: Job? = null - override fun stopListening() { - if (!isListening) { - return + fun unregisterListeners() { + connectivityManager.unregisterNetworkCallback(networkCallback) + connectivityManager.unregisterNetworkCallback(defaultNetworkCallback) + refreshJob?.cancel() + refreshJob = null } - connectivityManager.unregisterNetworkCallback(networkCallback) - connectivityManager.unregisterNetworkCallback(defaultNetworkCallback) - isListening = false - } - private fun reset() { - defaultNetwork = null - networkMap.clear() - } - - override fun refresh() { - reset() - for (network in connectivityManager.allNetworks) { - connectivityManager.requestBandwidthUpdate(network) - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: continue - // always skip restricted networks - if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)) { - continue - } - networkMap[network] = NetworkData( - network, - networkCapabilities = capabilities, - linkProperties = connectivityManager.getLinkProperties(network) + fun registerListeners() { + connectivityManager.registerNetworkCallback( + NetworkRequest.Builder() + // these capability filters are added by default + .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build(), + networkCallback ) + connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback) + if (refreshJob == null) { + refreshJob = coroutineScope.launch { + refreshFlow.collect { refresh() } + } + } } - defaultNetwork = connectivityManager.activeNetwork - update() - } - override fun setCallback(callback: (ConnectivityStatus) -> Unit) { - this.callback = callback - update() - } + private var defaultNetwork: Network? = null + private val networkMap = mutableMapOf() - override fun clearCallback() { - callback = null - } + private val networks + get() = ConnectivityStatus(networkMap[defaultNetwork], networkMap.values.toList()) - private fun updateNetworkCapabilities( - network: Network, - networkCapabilities: NetworkCapabilities - ) { - networkMap[network] = - networkMap[network]?.copy(networkCapabilities = networkCapabilities) - ?: NetworkData(network, networkCapabilities = networkCapabilities) - update() - } + private fun reset() { + defaultNetwork = null + networkMap.clear() + } - private fun updateLinkProperties(network: Network, linkProperties: LinkProperties) { - networkMap[network] = - networkMap[network]?.copy(linkProperties = linkProperties) - ?: NetworkData(network, linkProperties = linkProperties) - update() - } + private fun refresh() { + vibrationHelper.tick() + reset() + for (network in connectivityManager.allNetworks) { + connectivityManager.requestBandwidthUpdate(network) + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: continue + // always skip restricted networks + if ( + !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + ) { + continue + } + networkMap[network] = NetworkData( + network, + networkCapabilities = capabilities, + linkProperties = connectivityManager.getLinkProperties(network) + ) + } + defaultNetwork = connectivityManager.activeNetwork + update() + } - private fun updateBlockedStatus(network: Network, blocked: Boolean) { - networkMap[network] = - networkMap[network]?.copy(isBlocked = blocked) - ?: NetworkData(network, isBlocked = blocked) - update() - } + private fun updateNetworkCapabilities( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + networkMap[network] = + networkMap[network]?.copy(networkCapabilities = networkCapabilities) + ?: NetworkData(network, networkCapabilities = networkCapabilities) + update() + } - private fun removeNetwork(network: Network) { - networkMap.remove(network) - update() - } + private fun updateLinkProperties(network: Network, linkProperties: LinkProperties) { + networkMap[network] = + networkMap[network]?.copy(linkProperties = linkProperties) + ?: NetworkData(network, linkProperties = linkProperties) + update() + } - private fun setNetworkDefault(network: Network) { - defaultNetwork = network - update() - } + private fun updateBlockedStatus(network: Network, blocked: Boolean) { + networkMap[network] = + networkMap[network]?.copy(isBlocked = blocked) + ?: NetworkData(network, isBlocked = blocked) + update() + } - private fun unsetNetworkDefault() { - defaultNetwork = null - update() - } + private fun removeNetwork(network: Network) { + networkMap.remove(network) + update() + } - private fun update() { - callback?.invoke(networks) - } + private fun setNetworkDefault(network: Network) { + defaultNetwork = network + update() + } - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities - ) = - updateNetworkCapabilities(network, networkCapabilities) + private fun unsetNetworkDefault() { + defaultNetwork = null + update() + } + + private fun update() { + sendChannel.takeUnless { it.isClosedForSend }?.offer(networks) + } - override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = - updateLinkProperties(network, linkProperties) + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) = updateNetworkCapabilities(network, networkCapabilities) - override fun onBlockedStatusChanged(network: Network, blocked: Boolean) = - updateBlockedStatus(network, blocked) + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = + updateLinkProperties(network, linkProperties) - override fun onLost(network: Network) = removeNetwork(network) - } + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) = + updateBlockedStatus(network, blocked) - private val defaultNetworkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) = setNetworkDefault(network) - override fun onLost(network: Network) = unsetNetworkDefault() + override fun onLost(network: Network) = removeNetwork(network) + } + + private val defaultNetworkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = setNetworkDefault(network) + override fun onLost(network: Network) = unsetNetworkDefault() + } } } diff --git a/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatus.kt b/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatus.kt index 130c249..d6eeca1 100644 --- a/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatus.kt +++ b/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatus.kt @@ -86,6 +86,8 @@ data class TelephonyStatus( } companion object { + val EMPTY = TelephonyStatus(listOf()) + private fun networkTypeToString(networkType: Int): String = when (networkType) { TelephonyManager.NETWORK_TYPE_UNKNOWN -> "UNKNOWN" diff --git a/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusListener.kt b/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusListener.kt index ec14a8d..be8c667 100644 --- a/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusListener.kt +++ b/app/src/main/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusListener.kt @@ -29,62 +29,58 @@ import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID import android.telephony.TelephonyDisplayInfo import android.telephony.TelephonyManager import androidx.core.content.ContextCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner +import com.devrel.android.minwos.data.util.RefreshFlow +import com.devrel.android.minwos.data.util.VibrationHelper import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.shareIn val IS_DISPLAY_INFO_SUPPORTED = Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -interface TelephonyStatusListener : DefaultLifecycleObserver { - fun startListening() - fun stopListening() +interface TelephonyStatusListener { + val flow: SharedFlow fun refresh() - fun setCallback(callback: (TelephonyStatus) -> Unit) - fun clearCallback() - - override fun onStart(owner: LifecycleOwner) = startListening() - override fun onStop(owner: LifecycleOwner) = stopListening() + fun recheckPermissions() } +@ExperimentalCoroutinesApi class TelephonyStatusListenerImpl @Inject constructor( @ApplicationContext private val context: Context, private val telephonyManager: TelephonyManager, - private val subscriptionManager: SubscriptionManager + private val subscriptionManager: SubscriptionManager, + private val coroutineScope: CoroutineScope, + private val vibrationHelper: VibrationHelper ) : TelephonyStatusListener { - private val subscriptionMap = mutableMapOf() - private val telephonyManagerMap = mutableMapOf() - private val displayInfoListeners = mutableMapOf() - private val stateListeners = mutableMapOf() - - private val status - get() = TelephonyStatus(subscriptionMap.values.sortedBy { it.subscription }) + private val refreshFlow = RefreshFlow() + private val hasPermissions = MutableStateFlow(hasPhoneStatePermission()) - private var isListening = false - private var callback: ((TelephonyStatus) -> Unit)? = null - - init { - refreshSubscriptions() + override fun refresh() { + refreshFlow.emit() } - private fun refreshSubscriptions() { - subscriptionMap.clear() - getSubscriptionIds().forEach { - val manager = telephonyManagerMap.getOrPut( - it, - { telephonyManager.createForSubscriptionId(it) } - ) - val simInfo = SimInfo.getFromTelephonyManager(manager) ?: return@forEach - subscriptionMap[it] = - TelephonyStatus.TelephonyData(SubscriptionInfo.getForId(it), simInfo) - } - telephonyManagerMap.keys.forEach { subscriptionId -> - subscriptionId.takeIf { subscriptionMap.containsKey(it) }?.let { - removeListenersForSubscriptionId(it) - } - } + override fun recheckPermissions() { + hasPermissions.value = hasPhoneStatePermission() } + override val flow = callbackFlow { + val manager = FlowManager(this@callbackFlow) + manager.startListening() + awaitClose { manager.stopListening() } + }.shareIn(coroutineScope, SharingStarted.WhileSubscribed(5000L), 1) + + fun hasPhoneStatePermission() = ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + @SuppressLint("MissingPermission") private fun getSubscriptionIds(): List { val ids = mutableSetOf() @@ -113,133 +109,175 @@ class TelephonyStatusListenerImpl @Inject constructor( return ids.toList() } - override fun refresh() { - refreshSubscriptions() - update() - if (isListening) { - startListening() - } - } + private inner class FlowManager( + private val sendChannel: SendChannel + ) { + private var refreshJob: Job? = null + private val subscriptionMap = mutableMapOf() + private val telephonyManagerMap = mutableMapOf() + private val displayInfoListeners = mutableMapOf() + private val stateListeners = mutableMapOf() - override fun startListening() { - subscriptionMap.keys.forEach { subscriptionId -> - addListenersForSubscriptionId(subscriptionId) + private val status + get() = TelephonyStatus(subscriptionMap.values.sortedBy { it.subscription }) + + private var isListening = false + + fun startListening() { + subscriptionMap.keys.forEach { subscriptionId -> + addListenersForSubscriptionId(subscriptionId) + } + isListening = true + if (refreshJob == null) { + refreshJob = Job().also { + coroutineScope.launch(it) { refreshFlow.collect { refresh() } } + coroutineScope.launch(it) { hasPermissions.collect { startListening() } } + } + } } - isListening = true - } - override fun stopListening() { - subscriptionMap.keys.forEach { subscriptionId -> - removeListenersForSubscriptionId(subscriptionId) + fun stopListening() { + subscriptionMap.keys.forEach { subscriptionId -> + removeListenersForSubscriptionId(subscriptionId) + } + isListening = false + refreshJob?.cancel() + refreshJob = null } - isListening = false - } - private fun addListenersForSubscriptionId(subscriptionId: Int) { - val telephonyManager = telephonyManagerMap.get(subscriptionId) ?: return + fun refresh() { + vibrationHelper.tick() + refreshSubscriptions() + update() + if (isListening) { + startListening() + } + } - stateListeners.computeIfAbsent(subscriptionId) { - val stateListener = StateListener(subscriptionId) - telephonyManager.listen( - stateListener, - PhoneStateListener.LISTEN_DATA_CONNECTION_STATE - or PhoneStateListener.LISTEN_SERVICE_STATE - or PhoneStateListener.LISTEN_SIGNAL_STRENGTHS - ) - stateListener + init { + refreshSubscriptions() } - displayInfoListeners.takeIf { IS_DISPLAY_INFO_SUPPORTED && hasPhoneStatePermission() } - ?.computeIfAbsent(subscriptionId) { - val displayInfoListener = DisplayInfoListener(subscriptionId) - telephonyManager.listen( - displayInfoListener, - PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED + + private fun refreshSubscriptions() { + subscriptionMap.clear() + getSubscriptionIds().forEach { + val manager = telephonyManagerMap.getOrPut( + it, + { telephonyManager.createForSubscriptionId(it) } ) - displayInfoListener + val simInfo = SimInfo.getFromTelephonyManager(manager) ?: return@forEach + subscriptionMap[it] = + TelephonyStatus.TelephonyData(SubscriptionInfo.getForId(it), simInfo) } - } + telephonyManagerMap.keys.forEach { subscriptionId -> + subscriptionId.takeIf { subscriptionMap.containsKey(it) }?.let { + removeListenersForSubscriptionId(it) + } + } + } - private fun removeListenersForSubscriptionId(subscriptionId: Int) { - val telephonyManager = telephonyManagerMap[subscriptionId] ?: return + private fun addListenersForSubscriptionId(subscriptionId: Int) { + val telephonyManager = telephonyManagerMap.get(subscriptionId) ?: return - stateListeners.computeIfPresent(subscriptionId) { _, listener -> - telephonyManager.listen(listener, PhoneStateListener.LISTEN_NONE) - null - } - displayInfoListeners.computeIfPresent(subscriptionId) { _, listener -> - telephonyManager.listen(listener, PhoneStateListener.LISTEN_NONE) - null + stateListeners.computeIfAbsent(subscriptionId) { + // PhoneStateListener requires a Looper, so we have to construct it in main + val stateListener = runBlocking(Dispatchers.Main) { + StateListener(subscriptionId) + } + telephonyManager.listen( + stateListener, + PhoneStateListener.LISTEN_DATA_CONNECTION_STATE + or PhoneStateListener.LISTEN_SERVICE_STATE + or PhoneStateListener.LISTEN_SIGNAL_STRENGTHS + ) + stateListener + } + displayInfoListeners + .takeIf { IS_DISPLAY_INFO_SUPPORTED && hasPhoneStatePermission() } + ?.computeIfAbsent(subscriptionId) { + // PhoneStateListener requires a Looper, so we have to construct it in main + val displayInfoListener = runBlocking(Dispatchers.Main) { + DisplayInfoListener(subscriptionId) + } + telephonyManager.listen( + displayInfoListener, + PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED + ) + displayInfoListener + } } - } - - override fun setCallback(callback: (TelephonyStatus) -> Unit) { - this.callback = callback - update() - } - override fun clearCallback() { - callback = null - } + private fun removeListenersForSubscriptionId(subscriptionId: Int) { + val telephonyManager = telephonyManagerMap[subscriptionId] ?: return - private fun hasPhoneStatePermission() = ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_PHONE_STATE - ) == PackageManager.PERMISSION_GRANTED + stateListeners.computeIfPresent(subscriptionId) { _, listener -> + telephonyManager.listen(listener, PhoneStateListener.LISTEN_NONE) + null + } + displayInfoListeners.computeIfPresent(subscriptionId) { _, listener -> + telephonyManager.listen(listener, PhoneStateListener.LISTEN_NONE) + null + } + } - @SuppressLint("NewApi") - private fun updateDisplayInfo(subscriptionId: Int, telephonyDisplayInfo: TelephonyDisplayInfo) { - subscriptionMap.computeIfPresent(subscriptionId) { _, data -> - data.copy( - networkType = telephonyDisplayInfo.networkType, - overrideNetworkType = telephonyDisplayInfo.overrideNetworkType - ) + @SuppressLint("NewApi") + private fun updateDisplayInfo( + subscriptionId: Int, + telephonyDisplayInfo: TelephonyDisplayInfo + ) { + subscriptionMap.computeIfPresent(subscriptionId) { _, data -> + data.copy( + networkType = telephonyDisplayInfo.networkType, + overrideNetworkType = telephonyDisplayInfo.overrideNetworkType + ) + } + update() } - update() - } - private fun updateNetworkState(subscriptionId: Int, state: Int, networkType: Int) { - subscriptionMap.computeIfPresent(subscriptionId) { _, data -> - data.copy( - networkState = state, - networkType = networkType - ) + private fun updateNetworkState(subscriptionId: Int, state: Int, networkType: Int) { + subscriptionMap.computeIfPresent(subscriptionId) { _, data -> + data.copy( + networkState = state, + networkType = networkType + ) + } + update() } - update() - } - private fun updateServiceState(subscriptionId: Int, serviceState: ServiceState?) { - subscriptionMap.computeIfPresent(subscriptionId) { _, data -> - data.copy(serviceState = serviceState) + private fun updateServiceState(subscriptionId: Int, serviceState: ServiceState?) { + subscriptionMap.computeIfPresent(subscriptionId) { _, data -> + data.copy(serviceState = serviceState) + } + update() } - update() - } - private fun updateSignalStrength(subscriptionId: Int, signalStrength: SignalStrength?) { - subscriptionMap.computeIfPresent(subscriptionId) { _, data -> - data.copy(signalStrength = signalStrength) + private fun updateSignalStrength(subscriptionId: Int, signalStrength: SignalStrength?) { + subscriptionMap.computeIfPresent(subscriptionId) { _, data -> + data.copy(signalStrength = signalStrength) + } + update() } - update() - } - private fun update() { - callback?.invoke(status) - } + private fun update() { + sendChannel.takeUnless { it.isClosedForSend }?.offer(status) + } - private inner class DisplayInfoListener(private val subscriptionId: Int) : - PhoneStateListener() { - @SuppressLint("MissingPermission") - override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) = - updateDisplayInfo(subscriptionId, telephonyDisplayInfo) - } + private inner class DisplayInfoListener(private val subscriptionId: Int) : + PhoneStateListener() { + @SuppressLint("MissingPermission") + override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) = + updateDisplayInfo(subscriptionId, telephonyDisplayInfo) + } - private inner class StateListener(private val subscriptionId: Int) : PhoneStateListener() { - override fun onDataConnectionStateChanged(state: Int, networkType: Int) = - updateNetworkState(subscriptionId, state, networkType) + private inner class StateListener(private val subscriptionId: Int) : PhoneStateListener() { + override fun onDataConnectionStateChanged(state: Int, networkType: Int) = + updateNetworkState(subscriptionId, state, networkType) - override fun onServiceStateChanged(serviceState: ServiceState?) = - updateServiceState(subscriptionId, serviceState) + override fun onServiceStateChanged(serviceState: ServiceState?) = + updateServiceState(subscriptionId, serviceState) - override fun onSignalStrengthsChanged(signalStrength: SignalStrength?) = - updateSignalStrength(subscriptionId, signalStrength) + override fun onSignalStrengthsChanged(signalStrength: SignalStrength?) = + updateSignalStrength(subscriptionId, signalStrength) + } } } diff --git a/app/src/main/java/com/devrel/android/minwos/data/util/RefreshFlow.kt b/app/src/main/java/com/devrel/android/minwos/data/util/RefreshFlow.kt new file mode 100644 index 0000000..a41db68 --- /dev/null +++ b/app/src/main/java/com/devrel/android/minwos/data/util/RefreshFlow.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.devrel.android.minwos.data.util + +import android.os.SystemClock +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect + +/** A special type of hot flow that prevents "refresh spam" by dismissing refreshes emitted in a + * short time period after the last accepted refresh. + */ +class RefreshFlow( + private val debounceTimeMillis: Long = 5_000L, + private inline val getElapsedTime: () -> Long = { SystemClock.elapsedRealtime() } +) { + private val backingFlow = MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST) + + fun emit() { + backingFlow.tryEmit(getElapsedTime()) + } + + suspend fun collect(collector: FlowCollector) { + backingFlow.collect { timestamp -> + // dismiss a buffered refresh that hasn't been accepted by any collector + if (getElapsedTime() - timestamp <= 500) { + collector.emit(Unit) + // suspend refreshing for a few seconds to prevent spam + delay(debounceTimeMillis) + } + } + } + + suspend inline fun collect(crossinline action: suspend () -> Unit): Unit = + collect(object : FlowCollector { + override suspend fun emit(value: Unit) = action() + }) +} diff --git a/app/src/main/java/com/devrel/android/minwos/data/util/VibrationHelper.kt b/app/src/main/java/com/devrel/android/minwos/data/util/VibrationHelper.kt new file mode 100644 index 0000000..6829969 --- /dev/null +++ b/app/src/main/java/com/devrel/android/minwos/data/util/VibrationHelper.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.devrel.android.minwos.data.util + +import android.os.VibrationEffect +import android.os.Vibrator +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VibrationHelper @Inject constructor(private val vibrator: Vibrator) { + private val tickEffect = VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)!! + + fun tick() = vibrator.vibrate(tickEffect) +} diff --git a/app/src/main/java/com/devrel/android/minwos/service/ForegroundStatusService.kt b/app/src/main/java/com/devrel/android/minwos/service/ForegroundStatusService.kt index 999591c..948f8d6 100644 --- a/app/src/main/java/com/devrel/android/minwos/service/ForegroundStatusService.kt +++ b/app/src/main/java/com/devrel/android/minwos/service/ForegroundStatusService.kt @@ -19,11 +19,13 @@ package com.devrel.android.minwos.service import android.app.NotificationManager import android.content.Intent import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope import com.devrel.android.minwos.data.networks.ConnectivityStatus import com.devrel.android.minwos.data.networks.ConnectivityStatusListener import com.devrel.android.minwos.ui.notification.NetworkNotificationFactory import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.collect @AndroidEntryPoint class ForegroundStatusService : LifecycleService() { @@ -59,8 +61,11 @@ class ForegroundStatusService : LifecycleService() { notificationManager.createNotificationChannel( networkNotificationFactory.createNotificationChannel() ) - lifecycle.addObserver(connectivityStatusListener) - connectivityStatusListener.setCallback { updateNotification(it.defaultNetwork) } + lifecycleScope.launchWhenStarted { + connectivityStatusListener.flow.collect { + updateNotification(it.defaultNetwork) + } + } } private fun stopForegroundService() { diff --git a/app/src/main/java/com/devrel/android/minwos/ui/MainActivity.kt b/app/src/main/java/com/devrel/android/minwos/ui/MainActivity.kt index 8116978..96ff7db 100644 --- a/app/src/main/java/com/devrel/android/minwos/ui/MainActivity.kt +++ b/app/src/main/java/com/devrel/android/minwos/ui/MainActivity.kt @@ -50,7 +50,5 @@ class MainActivity : AppCompatActivity() { } supportActionBar?.subtitle = getString(R.string.app_name_version, BuildConfig.VERSION_NAME) binding.navView.setupWithNavController(navController) - lifecycle.addObserver(connectivityStatusListener) - lifecycle.addObserver(telephonyStatusListener) } } diff --git a/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragment.kt b/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragment.kt index ea95f78..1016ad9 100644 --- a/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragment.kt +++ b/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksFragment.kt @@ -27,13 +27,17 @@ import android.view.ViewGroup import androidx.core.content.ContextCompat.startForegroundService import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.devrel.android.minwos.R import com.devrel.android.minwos.databinding.FragmentNetworksBinding import com.devrel.android.minwos.service.ForegroundStatusService import com.devrel.android.minwos.ui.help.HelpDialog import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @AndroidEntryPoint class NetworksFragment : Fragment() { @@ -68,12 +72,12 @@ class NetworksFragment : Fragment() { layoutManager = viewManager adapter = viewAdapter } - viewModel.connectivityStatus.observe( - viewLifecycleOwner, - Observer { networkStatus -> - viewAdapter.connectivityStatus = networkStatus - } - ) + lifecycleScope.launch { + viewModel.connectivityStatus.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .collect { networkStatus -> + viewAdapter.connectivityStatus = networkStatus + } + } } private fun showHelp() { diff --git a/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModel.kt b/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModel.kt index a4431b5..b96528b 100644 --- a/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModel.kt +++ b/app/src/main/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModel.kt @@ -16,29 +16,24 @@ package com.devrel.android.minwos.ui.fragments.networks -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.devrel.android.minwos.data.networks.ConnectivityStatus import com.devrel.android.minwos.data.networks.ConnectivityStatusListener import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.stateIn @HiltViewModel class NetworksViewModel @Inject constructor( private val connectivityStatusListener: ConnectivityStatusListener ) : ViewModel() { - private val connectivityStatusMutable = MutableLiveData() - val connectivityStatus: LiveData get() = connectivityStatusMutable - - init { - connectivityStatusListener.setCallback { connectivityStatusMutable.postValue(it) } - } + val connectivityStatus = connectivityStatusListener.flow.stateIn( + viewModelScope, + WhileSubscribed(), + ConnectivityStatus.EMPTY + ) fun refresh() = connectivityStatusListener.refresh() - - override fun onCleared() { - connectivityStatusListener.clearCallback() - super.onCleared() - } } diff --git a/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragment.kt b/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragment.kt index a18d0d4..d99d8e1 100644 --- a/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragment.kt +++ b/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateFragment.kt @@ -29,12 +29,17 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ActivityCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.devrel.android.minwos.R import com.devrel.android.minwos.data.phonestate.IS_DISPLAY_INFO_SUPPORTED import com.devrel.android.minwos.databinding.FragmentPhonestateBinding import com.devrel.android.minwos.ui.help.HelpDialog import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @AndroidEntryPoint class PhoneStateFragment : Fragment() { @@ -75,18 +80,15 @@ class PhoneStateFragment : Fragment() { layoutManager = viewManager adapter = viewAdapter } - viewModel.telephonyStatus.observe( - viewLifecycleOwner, - { telephonyStatus -> - viewAdapter.submitList(telephonyStatus.subscriptions) - } - ) - viewModel.permissionsGranted.observe( - viewLifecycleOwner, - { isGranted -> - binding.permissionsWarning.takeIf { isGranted }?.visibility = View.GONE - } - ) + lifecycleScope.launch { + viewModel.state.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .collect { state -> + viewAdapter.submitList(state.telephonyStatus.subscriptions) + if (state.permissionsGranted) { + binding.permissionsWarning.visibility = View.GONE + } + } + } } override fun onStart() { diff --git a/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModel.kt b/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModel.kt index e3f56d1..b953591 100644 --- a/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModel.kt +++ b/app/src/main/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModel.kt @@ -16,39 +16,41 @@ package com.devrel.android.minwos.ui.fragments.phonestate -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.devrel.android.minwos.data.phonestate.TelephonyStatus import com.devrel.android.minwos.data.phonestate.TelephonyStatusListener import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn @HiltViewModel class PhoneStateViewModel @Inject constructor( private val telephonyStatusListener: TelephonyStatusListener ) : ViewModel() { - private val telephonyStatusMutable = MutableLiveData() - val telephonyStatus: LiveData get() = telephonyStatusMutable + private val permissionsGrantedFlow = MutableStateFlow(false) - private val permissionsGrantedMutable = MutableLiveData(false) - val permissionsGranted: LiveData get() = permissionsGrantedMutable - - init { - telephonyStatusListener.setCallback { telephonyStatusMutable.postValue(it) } - } + val state = + telephonyStatusListener.flow.combine(permissionsGrantedFlow) { status, permissions -> + State(status, permissions) + }.stateIn( + viewModelScope, + WhileSubscribed(), + State() + ) fun refresh() = telephonyStatusListener.refresh() fun updatePermissions(granted: Boolean) { - if (granted) { - telephonyStatusListener.startListening() - } - permissionsGrantedMutable.postValue(granted) + permissionsGrantedFlow.value = granted + telephonyStatusListener.recheckPermissions() } - override fun onCleared() { - telephonyStatusListener.clearCallback() - super.onCleared() - } + data class State( + val telephonyStatus: TelephonyStatus = TelephonyStatus.EMPTY, + val permissionsGranted: Boolean = false + ) } diff --git a/app/src/test/java/com/devrel/android/minwos/TestUtil.kt b/app/src/test/java/com/devrel/android/minwos/TestUtil.kt new file mode 100644 index 0000000..5115613 --- /dev/null +++ b/app/src/test/java/com/devrel/android/minwos/TestUtil.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.devrel.android.minwos + +import android.os.Build +import java.lang.reflect.Field +import java.lang.reflect.Modifier + +object TestUtil { + private val sdkField = Build.VERSION::class.java.getField("SDK_INT") + private val oldSdkValue = Build.VERSION.SDK_INT + + init { + sdkField.isAccessible = true + val modifiersField = Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(sdkField, sdkField.modifiers and Modifier.FINAL.inv()) + } + + fun resetVersionSdkInt() = setVersionSdkInt(oldSdkValue) + + fun setVersionSdkInt(newValue: Int) { + sdkField.set(null, newValue) + } +} diff --git a/app/src/test/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusTest.kt b/app/src/test/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusTest.kt index 2752fce..1d979f8 100644 --- a/app/src/test/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusTest.kt +++ b/app/src/test/java/com/devrel/android/minwos/data/phonestate/TelephonyStatusTest.kt @@ -16,23 +16,40 @@ package com.devrel.android.minwos.data.phonestate +import android.os.Build +import android.telephony.AccessNetworkConstants +import android.telephony.NetworkRegistrationInfo import android.telephony.ServiceState import android.telephony.TelephonyDisplayInfo import android.telephony.TelephonyManager +import com.devrel.android.minwos.TestUtil import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before import org.junit.Test import org.mockito.Mockito.`when` import org.mockito.Mockito.mock class TelephonyStatusTest { - private val baseTelephonyData = - TelephonyStatus.TelephonyData(SubscriptionInfo(1, 0), SimInfo("", "")) + private val baseTelephonyData = TelephonyStatus.TelephonyData( + SubscriptionInfo(1, 0), SimInfo("", "") + ) + + @Before + fun setUp() { + TestUtil.setVersionSdkInt(Build.VERSION_CODES.R) + } + + @After + fun tearDown() { + TestUtil.resetVersionSdkInt() + } @Test fun `uses data from TelephonyDisplayInfo`() { var underTest = baseTelephonyData assertThat(underTest.networkTypeString).isEqualTo("UNKNOWN") - assertThat(underTest.overrideNetworkTypeString).isEqualTo("UNKNOWN") + assertThat(underTest.overrideNetworkTypeString).isEqualTo(null) underTest = baseTelephonyData.copy( networkType = TelephonyManager.NETWORK_TYPE_CDMA, @@ -59,14 +76,23 @@ class TelephonyStatusTest { fun `parses string from ServiceState`() { val base = baseTelephonyData val serviceState = mock(ServiceState::class.java) + val networkRegistrationInfo = mock(NetworkRegistrationInfo::class.java) + `when`(networkRegistrationInfo.transportType) + .thenReturn(AccessNetworkConstants.TRANSPORT_TYPE_WWAN) + `when`(networkRegistrationInfo.domain) + .thenReturn(NetworkRegistrationInfo.DOMAIN_PS) + `when`(serviceState.networkRegistrationInfoList).thenReturn(listOf(networkRegistrationInfo)) - `when`(serviceState.toString()).thenReturn("nrState=NOT_RESTRICTED nrState=CONNECTED") + `when`(networkRegistrationInfo.toString()) + .thenReturn("nrState=NOT_RESTRICTED nrState=CONNECTED") assertThat(base.copy(serviceState = serviceState).nrState).isEqualTo("CONNECTED") - `when`(serviceState.toString()).thenReturn("nrState=NOT_RESTRICTED") + `when`(networkRegistrationInfo.toString()) + .thenReturn("nrState=NOT_RESTRICTED") assertThat(base.copy(serviceState = serviceState).nrState).isEqualTo("NOT_RESTRICTED") - `when`(serviceState.toString()).thenReturn("lorem ipsum") - assertThat(base.copy(serviceState = serviceState).nrState).isEqualTo("UNKNOWN") + `when`(networkRegistrationInfo.toString()) + .thenReturn("lorem ipsum") + assertThat(base.copy(serviceState = serviceState).nrState).isEqualTo(null) } } diff --git a/app/src/test/java/com/devrel/android/minwos/data/util/RefreshFlowTest.kt b/app/src/test/java/com/devrel/android/minwos/data/util/RefreshFlowTest.kt new file mode 100644 index 0000000..f94a9c1 --- /dev/null +++ b/app/src/test/java/com/devrel/android/minwos/data/util/RefreshFlowTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.devrel.android.minwos.data.util + +import com.google.common.truth.Truth +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Test + +class RefreshFlowTest { + @Test + fun `an emit is collected`() = + runBlocking { + withTimeout(10_000) { + val underTest = RefreshFlow(getElapsedTime = { 0 }) + var collected = 0 + val job = launch(Job()) { underTest.collect { collected++ } } + delay(100) + underTest.emit() + delay(100) + job.cancel() + Truth.assertThat(collected).isEqualTo(1) + } + } + + @Test + fun `consecutive emits are ignored`() = + runBlocking { + withTimeout(10_000) { + val underTest = RefreshFlow(500, getElapsedTime = { 0 }) + var collected = 0 + val job = launch(Job()) { underTest.collect { collected++ } } + delay(100) + underTest.emit() + underTest.emit() + underTest.emit() + delay(1000) + underTest.emit() + underTest.emit() + delay(100) + job.cancel() + Truth.assertThat(collected).isEqualTo(2) + } + } +} diff --git a/app/src/test/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModelTest.kt b/app/src/test/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModelTest.kt index d438376..9957a9c 100644 --- a/app/src/test/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModelTest.kt +++ b/app/src/test/java/com/devrel/android/minwos/ui/fragments/networks/NetworksViewModelTest.kt @@ -16,12 +16,19 @@ package com.devrel.android.minwos.ui.fragments.networks -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.devrel.android.minwos.data.networks.ConnectivityStatus import com.devrel.android.minwos.data.networks.ConnectivityStatusListener import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.times @@ -29,15 +36,21 @@ import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations class NetworksViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @Mock lateinit var connectivityListener: ConnectivityStatusListener + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + @Before fun setUp() { MockitoAnnotations.initMocks(this) + Dispatchers.setMain(mainThreadSurrogate) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + mainThreadSurrogate.close() } @Test @@ -49,21 +62,17 @@ class NetworksViewModelTest { } @Test - fun `ConnectivityStatus in LiveData`() { - var cbk: ((ConnectivityStatus) -> Unit)? = null + fun `ConnectivityStatus in StateFlow`() = runBlocking { + val flow = MutableSharedFlow(1) val underTest = NetworksViewModel(object : ConnectivityStatusListener { - override fun startListening() {} - override fun stopListening() {} + override val flow get() = flow override fun refresh() {} - override fun clearCallback() {} - override fun setCallback(callback: (ConnectivityStatus) -> Unit) { - cbk = callback - } }) - val status = ConnectivityStatus(null, listOf()) - assertThat(cbk).isNotNull() - assertThat(underTest.connectivityStatus.value).isNull() - cbk!!(status) + val status = ConnectivityStatus(null, listOf(ConnectivityStatus.NetworkData(1))) + assertThat(underTest.connectivityStatus.value).isNotSameInstanceAs(status) + flow.emit(status) + // collect() to trigger the flow, because the StateFlow has WhileSubscribed() + withTimeoutOrNull(100) { underTest.connectivityStatus.collect { } } assertThat(underTest.connectivityStatus.value).isSameInstanceAs(status) } } diff --git a/app/src/test/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModelTest.kt b/app/src/test/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModelTest.kt index 323dca8..0be547e 100644 --- a/app/src/test/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModelTest.kt +++ b/app/src/test/java/com/devrel/android/minwos/ui/fragments/phonestate/PhoneStateViewModelTest.kt @@ -16,12 +16,21 @@ package com.devrel.android.minwos.ui.fragments.phonestate -import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.devrel.android.minwos.data.phonestate.SimInfo +import com.devrel.android.minwos.data.phonestate.SubscriptionInfo import com.devrel.android.minwos.data.phonestate.TelephonyStatus import com.devrel.android.minwos.data.phonestate.TelephonyStatusListener import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.Mockito.times @@ -29,15 +38,21 @@ import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations class PhoneStateViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @Mock lateinit var telephonyListener: TelephonyStatusListener + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + @Before fun setUp() { MockitoAnnotations.initMocks(this) + Dispatchers.setMain(mainThreadSurrogate) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + mainThreadSurrogate.close() } @Test @@ -49,21 +64,29 @@ class PhoneStateViewModelTest { } @Test - fun `TelephonyStatus in LiveData`() { - var cbk: ((TelephonyStatus) -> Unit)? = null + fun `TelephonyStatus in StateFlow`() = runBlocking { + val flow = MutableSharedFlow(1) val underTest = PhoneStateViewModel(object : TelephonyStatusListener { - override fun startListening() {} - override fun stopListening() {} + override val flow get() = flow override fun refresh() {} - override fun clearCallback() {} - override fun setCallback(callback: (TelephonyStatus) -> Unit) { - cbk = callback - } + override fun recheckPermissions() {} }) - val status = TelephonyStatus(listOf()) - assertThat(cbk).isNotNull() - assertThat(underTest.telephonyStatus.value).isNull() - cbk!!(status) - assertThat(underTest.telephonyStatus.value).isSameInstanceAs(status) + + val status = TelephonyStatus( + listOf( + TelephonyStatus.TelephonyData(SubscriptionInfo(1, 2), SimInfo("", "")) + ) + ) + flow.emit(status) + + // collect() to trigger the flow, because the StateFlow has WhileSubscribed() + withTimeoutOrNull(100) { underTest.state.collect { } } + assertThat(underTest.state.value.telephonyStatus).isSameInstanceAs(status) + assertThat(underTest.state.value.permissionsGranted).isFalse() + + underTest.updatePermissions(true) + withTimeoutOrNull(100) { underTest.state.collect { } } + assertThat(underTest.state.value.telephonyStatus).isSameInstanceAs(status) + assertThat(underTest.state.value.permissionsGranted).isTrue() } }