diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java index d0da18aaba056..0793335246a8e 100644 --- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java @@ -255,6 +255,16 @@ public class LogModule { return factory.create("MediaCarouselCtlrLog", 20); } + /** + * Provides a {@link LogBuffer} for use in the status bar connectivity pipeline + */ + @Provides + @SysUISingleton + @StatusBarConnectivityLog + public static LogBuffer provideStatusBarConnectivityBuffer(LogBufferFactory factory) { + return factory.create("StatusBarConnectivityLog", 64); + } + /** Allows logging buffers to be tweaked via adb on debug builds but not on prod builds. */ @Provides @SysUISingleton diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/StatusBarConnectivityLog.java b/packages/SystemUI/src/com/android/systemui/log/dagger/StatusBarConnectivityLog.java new file mode 100644 index 0000000000000..f03fbcba41b72 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/StatusBarConnectivityLog.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 + * + * http://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.android.systemui.log.dagger; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.android.systemui.log.LogBuffer; +import com.android.systemui.statusbar.pipeline.ConnectivityInfoProcessor; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +import javax.inject.Qualifier; + +/** + * A {@link LogBuffer} for events processed by {@link ConnectivityInfoProcessor} + */ +@Qualifier +@Documented +@Retention(RUNTIME) +public @interface StatusBarConnectivityLog { +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLogger.kt new file mode 100644 index 0000000000000..f88e9d67d25dc --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLogger.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 + * + * http://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.android.systemui.statusbar.pipeline + +import android.net.Network +import android.net.NetworkCapabilities +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.LogLevel +import com.android.systemui.log.dagger.StatusBarConnectivityLog +import javax.inject.Inject + +@SysUISingleton +class ConnectivityPipelineLogger @Inject constructor( + @StatusBarConnectivityLog private val buffer: LogBuffer, +) { + fun logOnCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + buffer.log( + TAG, + LogLevel.INFO, + { + int1 = network.getNetId() + str1 = networkCapabilities.toString() + }, + { + "onCapabilitiesChanged: net=$int1 capabilities=$str1" + } + ) + } + + fun logOnLost(network: Network) { + buffer.log( + TAG, + LogLevel.INFO, + { + int1 = network.getNetId() + }, + { + "onLost: net=$int1" + } + ) + } +} + +private const val TAG = "SbConnectivityPipeline" diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepo.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepo.kt new file mode 100644 index 0000000000000..e5980c3693ee2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepo.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * 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 + * + * http://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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.statusbar.pipeline.repository + +import android.annotation.SuppressLint +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.statusbar.pipeline.ConnectivityPipelineLogger +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.stateIn + +/** Repository that contains all relevant [NetworkCapabilites] for the current networks */ +@SysUISingleton +class NetworkCapabilitiesRepo @Inject constructor( + connectivityManager: ConnectivityManager, + @Application scope: CoroutineScope, + logger: ConnectivityPipelineLogger, +) { + @SuppressLint("MissingPermission") + val dataStream: StateFlow> = run { + var state = emptyMap() + callbackFlow { + val networkRequest: NetworkRequest = + NetworkRequest.Builder() + .clearCapabilities() + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + val callback = + // TODO (b/240569788): log these using [LogBuffer] + object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) { + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + logger.logOnCapabilitiesChanged(network, networkCapabilities) + state = + state.toMutableMap().also { + it[network.getNetId()] = + NetworkCapabilityInfo(network, networkCapabilities) + } + trySend(state) + } + + override fun onLost(network: Network) { + logger.logOnLost(network) + state = state.toMutableMap().also { it.remove(network.getNetId()) } + trySend(state) + } + } + connectivityManager.registerNetworkCallback(networkRequest, callback) + + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + } + .stateIn(scope, started = Lazily, initialValue = state) + } +} + +/** contains info about network capabilities. */ +data class NetworkCapabilityInfo( + val network: Network, + val capabilities: NetworkCapabilities, +) + +private const val TAG = "ConnectivityRepository" diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLoggerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLoggerTest.kt new file mode 100644 index 0000000000000..2915ae8dea88a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/ConnectivityPipelineLoggerTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 + * + * http://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.android.systemui.statusbar.pipeline + +import android.net.Network +import android.net.NetworkCapabilities +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.dump.DumpManager +import com.android.systemui.log.LogBufferFactory +import com.android.systemui.log.LogcatEchoTracker +import com.google.common.truth.Truth.assertThat +import java.io.PrintWriter +import java.io.StringWriter +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.mock + +@SmallTest +class ConnectivityPipelineLoggerTest : SysuiTestCase() { + private val buffer = LogBufferFactory(DumpManager(), mock(LogcatEchoTracker::class.java)) + .create("buffer", 10) + private val logger = ConnectivityPipelineLogger(buffer) + + @Test + fun testLogNetworkCapsChange_bufferHasInfo() { + logger.logOnCapabilitiesChanged(NET_1, NET_1_CAPS) + + val stringWriter = StringWriter() + buffer.dump(PrintWriter(stringWriter), tailLength = 0) + val actualString = stringWriter.toString() + + val expectedNetId = NET_1_ID.toString() + val expectedCaps = NET_1_CAPS.toString() + + assertThat(actualString).contains(expectedNetId) + assertThat(actualString).contains(expectedCaps) + } + + @Test + fun testLogOnLost_bufferHasNetIdOfLostNetwork() { + logger.logOnLost(NET_1) + + val stringWriter = StringWriter() + buffer.dump(PrintWriter(stringWriter), tailLength = 0) + val actualString = stringWriter.toString() + + val expectedNetId = NET_1_ID.toString() + + assertThat(actualString).contains(expectedNetId) + } + + private val NET_1_ID = 100 + private val NET_1 = com.android.systemui.util.mockito.mock().also { + Mockito.`when`(it.getNetId()).thenReturn(NET_1_ID) + } + private val NET_1_CAPS = NetworkCapabilities.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepoTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepoTest.kt new file mode 100644 index 0000000000000..40f8fbfd9af2e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/repository/NetworkCapabilitiesRepoTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * 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 + * + * http://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.android.systemui.statusbar.pipeline.repository + +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_CELLULAR +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.net.NetworkRequest +import android.test.suitebuilder.annotation.SmallTest +import android.testing.AndroidTestingRunner +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.pipeline.ConnectivityPipelineLogger +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.withArgCaptor +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.MockitoAnnotations + +// TODO(b/240619365): Update this test to use `runTest` when we update the testing library +@SmallTest +@RunWith(AndroidTestingRunner::class) +class NetworkCapabilitiesRepoTest : SysuiTestCase() { + @Mock private lateinit var connectivityManager: ConnectivityManager + @Mock private lateinit var logger: ConnectivityPipelineLogger + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + } + + @Test + fun testOnCapabilitiesChanged_oneNewNetwork_networkStored() = runBlocking { + // GIVEN a repo hooked up to [ConnectivityManager] + val scope = CoroutineScope(Dispatchers.Unconfined) + val repo = NetworkCapabilitiesRepo( + connectivityManager = connectivityManager, + scope = scope, + logger = logger, + ) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repo.dataStream.collect { + } + } + + val callback: NetworkCallback = withArgCaptor { + verify(connectivityManager) + .registerNetworkCallback(any(NetworkRequest::class.java), capture()) + } + + // WHEN a new network is added + callback.onCapabilitiesChanged(NET_1, NET_1_CAPS) + + val currentMap = repo.dataStream.value + + // THEN it is emitted from the flow + assertThat(currentMap[NET_1_ID]?.network).isEqualTo(NET_1) + assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_1_CAPS) + + job.cancel() + scope.cancel() + } + + @Test + fun testOnCapabilitiesChanged_twoNewNetworks_bothStored() = runBlocking { + // GIVEN a repo hooked up to [ConnectivityManager] + val scope = CoroutineScope(Dispatchers.Unconfined) + val repo = NetworkCapabilitiesRepo( + connectivityManager = connectivityManager, + scope = scope, + logger = logger, + ) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repo.dataStream.collect { + } + } + + val callback: NetworkCallback = withArgCaptor { + verify(connectivityManager) + .registerNetworkCallback(any(NetworkRequest::class.java), capture()) + } + + // WHEN two new networks are added + callback.onCapabilitiesChanged(NET_1, NET_1_CAPS) + callback.onCapabilitiesChanged(NET_2, NET_2_CAPS) + + val currentMap = repo.dataStream.value + + // THEN the current state of the flow reflects 2 networks + assertThat(currentMap[NET_1_ID]?.network).isEqualTo(NET_1) + assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_1_CAPS) + assertThat(currentMap[NET_2_ID]?.network).isEqualTo(NET_2) + assertThat(currentMap[NET_2_ID]?.capabilities).isEqualTo(NET_2_CAPS) + + job.cancel() + scope.cancel() + } + + @Test + fun testOnCapabilitesChanged_newCapabilitiesForExistingNetwork_areCaptured() = runBlocking { + // GIVEN a repo hooked up to [ConnectivityManager] + val scope = CoroutineScope(Dispatchers.Unconfined) + val repo = NetworkCapabilitiesRepo( + connectivityManager = connectivityManager, + scope = scope, + logger = logger, + ) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repo.dataStream.collect { + } + } + + val callback: NetworkCallback = withArgCaptor { + verify(connectivityManager) + .registerNetworkCallback(any(NetworkRequest::class.java), capture()) + } + + // WHEN a network is added, and then its capabilities are changed + callback.onCapabilitiesChanged(NET_1, NET_1_CAPS) + callback.onCapabilitiesChanged(NET_1, NET_2_CAPS) + + val currentMap = repo.dataStream.value + + // THEN the current state of the flow reflects the new capabilities + assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_2_CAPS) + + job.cancel() + scope.cancel() + } + + @Test + fun testOnLost_networkIsRemoved() = runBlocking { + // GIVEN a repo hooked up to [ConnectivityManager] + val scope = CoroutineScope(Dispatchers.Unconfined) + val repo = NetworkCapabilitiesRepo( + connectivityManager = connectivityManager, + scope = scope, + logger = logger, + ) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repo.dataStream.collect { + } + } + + val callback: NetworkCallback = withArgCaptor { + verify(connectivityManager) + .registerNetworkCallback(any(NetworkRequest::class.java), capture()) + } + + // WHEN two new networks are added, and one is removed + callback.onCapabilitiesChanged(NET_1, NET_1_CAPS) + callback.onCapabilitiesChanged(NET_2, NET_2_CAPS) + callback.onLost(NET_1) + + val currentMap = repo.dataStream.value + + // THEN the current state of the flow reflects only the remaining network + assertThat(currentMap[NET_1_ID]).isNull() + assertThat(currentMap[NET_2_ID]?.network).isEqualTo(NET_2) + assertThat(currentMap[NET_2_ID]?.capabilities).isEqualTo(NET_2_CAPS) + + job.cancel() + scope.cancel() + } + + @Test + fun testOnLost_noNetworks_doesNotCrash() = runBlocking { + // GIVEN a repo hooked up to [ConnectivityManager] + val scope = CoroutineScope(Dispatchers.Unconfined) + val repo = NetworkCapabilitiesRepo( + connectivityManager = connectivityManager, + scope = scope, + logger = logger, + ) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + repo.dataStream.collect { + } + } + + val callback: NetworkCallback = withArgCaptor { + verify(connectivityManager) + .registerNetworkCallback(any(NetworkRequest::class.java), capture()) + } + + // WHEN no networks are added, and one is removed + callback.onLost(NET_1) + + val currentMap = repo.dataStream.value + + // THEN the current state of the flow shows no networks + assertThat(currentMap).isEmpty() + + job.cancel() + scope.cancel() + } + + private val NET_1_ID = 100 + private val NET_1 = mock().also { + whenever(it.getNetId()).thenReturn(NET_1_ID) + } + private val NET_2_ID = 200 + private val NET_2 = mock().also { + whenever(it.getNetId()).thenReturn(NET_2_ID) + } + + private val NET_1_CAPS = NetworkCapabilities.Builder() + .addTransportType(TRANSPORT_CELLULAR) + .addCapability(NET_CAPABILITY_VALIDATED) + .build() + + private val NET_2_CAPS = NetworkCapabilities.Builder() + .addTransportType(TRANSPORT_WIFI) + .addCapability(NET_CAPABILITY_NOT_METERED) + .addCapability(NET_CAPABILITY_VALIDATED) + .build() +}