/* * Copyright (C) 2020 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.networkstack import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.NotificationManager.IMPORTANCE_DEFAULT import android.app.NotificationManager.IMPORTANCE_NONE import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.content.Context import android.content.Intent import android.content.res.Resources import android.net.CaptivePortalData import android.net.ConnectivityManager import android.net.ConnectivityManager.EXTRA_NETWORK import android.net.ConnectivityManager.NetworkCallback import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.Uri import android.os.Handler import android.os.UserHandle import android.provider.Settings import android.testing.AndroidTestingRunner import android.testing.TestableLooper import android.testing.TestableLooper.RunWithLooper import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.android.dx.mockito.inline.extended.ExtendedMockito.verify import com.android.networkstack.NetworkStackNotifier.CHANNEL_CONNECTED import com.android.networkstack.NetworkStackNotifier.CHANNEL_VENUE_INFO import com.android.networkstack.NetworkStackNotifier.CONNECTED_NOTIFICATION_TIMEOUT_MS import com.android.networkstack.NetworkStackNotifier.Dependencies import com.android.networkstack.apishim.NetworkInformationShimImpl import com.android.modules.utils.build.SdkLevel.isAtLeastS import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.eq import org.mockito.ArgumentMatchers.intThat import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.any import org.mockito.Mockito.doReturn import org.mockito.Mockito.never import org.mockito.MockitoAnnotations import kotlin.reflect.KClass import kotlin.test.assertEquals @RunWith(AndroidTestingRunner::class) @SmallTest @RunWithLooper class NetworkStackNotifierTest { @Mock private lateinit var mContext: Context @Mock private lateinit var mCurrentUserContext: Context @Mock private lateinit var mAllUserContext: Context @Mock private lateinit var mDependencies: Dependencies @Mock private lateinit var mNm: NotificationManager @Mock private lateinit var mNotificationChannelsNm: NotificationManager @Mock private lateinit var mCm: ConnectivityManager @Mock private lateinit var mResources: Resources @Mock private lateinit var mPendingIntent: PendingIntent @Captor private lateinit var mNoteCaptor: ArgumentCaptor @Captor private lateinit var mNoteIdCaptor: ArgumentCaptor @Captor private lateinit var mIntentCaptor: ArgumentCaptor private lateinit var mLooper: TestableLooper private lateinit var mHandler: Handler private lateinit var mNotifier: NetworkStackNotifier private lateinit var mAllNetworksCb: NetworkCallback private lateinit var mDefaultNetworkCb: NetworkCallback // Lazy-init as CaptivePortalData does not exist on Q. private val mTestCapportLp by lazy { LinkProperties().apply { captivePortalData = CaptivePortalData.Builder() .setCaptive(false) .setVenueInfoUrl(Uri.parse(TEST_VENUE_INFO_URL)) .build() } } private val mTestCapportVenueUrlWithFriendlyNameLp by lazy { LinkProperties().apply { captivePortalData = CaptivePortalData.Builder() .setCaptive(false) .setVenueInfoUrl(Uri.parse(TEST_VENUE_INFO_URL)) .build() val networkShim = NetworkInformationShimImpl.newInstance() val captivePortalDataShim = networkShim.getCaptivePortalData(this) if (captivePortalDataShim != null) { networkShim.setCaptivePortalData(this, captivePortalDataShim .withVenueFriendlyName(TEST_NETWORK_FRIENDLY_NAME)) } } } private val TEST_NETWORK = Network(42) private val TEST_NETWORK_TAG = TEST_NETWORK.networkHandle.toString() private val TEST_SSID = "TestSsid" private val EMPTY_CAPABILITIES = NetworkCapabilities() private val VALIDATED_CAPABILITIES = NetworkCapabilities() .addTransportType(TRANSPORT_WIFI) .addCapability(NET_CAPABILITY_VALIDATED) private val TEST_CONNECTED_DESCRIPTION = "Connected" private val TEST_VENUE_DESCRIPTION = "Connected / Tap to view website" private val TEST_VENUE_INFO_URL = "https://testvenue.example.com/info" private val EMPTY_CAPPORT_LP = LinkProperties() private val TEST_NETWORK_FRIENDLY_NAME = "Network Friendly Name" @Before fun setUp() { MockitoAnnotations.initMocks(this) mLooper = TestableLooper.get(this) doReturn(mResources).`when`(mContext).resources doReturn(TEST_CONNECTED_DESCRIPTION).`when`(mResources).getString(R.string.connected) doReturn(TEST_VENUE_DESCRIPTION).`when`(mResources).getString(R.string.tap_for_info) // applicationInfo is used by Notification.Builder val realContext = InstrumentationRegistry.getInstrumentation().context doReturn(realContext.applicationInfo).`when`(mContext).applicationInfo doReturn(realContext.packageName).`when`(mContext).packageName doReturn(mCurrentUserContext).`when`(mContext).createPackageContextAsUser( realContext.packageName, 0, UserHandle.CURRENT) doReturn(mAllUserContext).`when`(mContext).createPackageContextAsUser( realContext.packageName, 0, UserHandle.ALL) mAllUserContext.mockService(Context.NOTIFICATION_SERVICE, NotificationManager::class, mNm) mContext.mockService(Context.NOTIFICATION_SERVICE, NotificationManager::class, mNotificationChannelsNm) mContext.mockService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class, mCm) doReturn(NotificationChannel(CHANNEL_VENUE_INFO, "TestChannel", IMPORTANCE_DEFAULT)) .`when`(mNotificationChannelsNm).getNotificationChannel(CHANNEL_VENUE_INFO) doReturn(mPendingIntent).`when`(mDependencies).getActivityPendingIntent( any(), any(), anyInt()) mNotifier = NetworkStackNotifier(mContext, mLooper.looper, mDependencies) mHandler = mNotifier.handler val allNetworksCbCaptor = ArgumentCaptor.forClass(NetworkCallback::class.java) verify(mCm).registerNetworkCallback(any() /* request */, allNetworksCbCaptor.capture(), eq(mHandler)) mAllNetworksCb = allNetworksCbCaptor.value val defaultNetworkCbCaptor = ArgumentCaptor.forClass(NetworkCallback::class.java) verify(mCm).registerDefaultNetworkCallback(defaultNetworkCbCaptor.capture(), eq(mHandler)) mDefaultNetworkCb = defaultNetworkCbCaptor.value } private fun Context.mockService(name: String, clazz: KClass, service: T) { doReturn(service).`when`(this).getSystemService(name) doReturn(name).`when`(this).getSystemServiceName(clazz.java) doReturn(service).`when`(this).getSystemService(clazz.java) } @Test fun testNoNotification() { onCapabilitiesChanged(EMPTY_CAPABILITIES) onCapabilitiesChanged(VALIDATED_CAPABILITIES) mLooper.processAllMessages() verify(mNm, never()).notify(any(), anyInt(), any()) } private fun verifyConnectedNotification(timeout: Long = CONNECTED_NOTIFICATION_TIMEOUT_MS) { verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture()) val note = mNoteCaptor.value assertEquals(mPendingIntent, note.contentIntent) assertEquals(CHANNEL_CONNECTED, note.channelId) assertEquals(timeout, note.timeoutAfter) verify(mDependencies).getActivityPendingIntent( eq(mCurrentUserContext), mIntentCaptor.capture(), intThat { it or FLAG_IMMUTABLE != 0 }) } private fun verifyCanceledNotificationAfterNetworkLost() { onLost(TEST_NETWORK) mLooper.processAllMessages() verify(mNm).cancel(TEST_NETWORK_TAG, mNoteIdCaptor.value) } private fun verifyCanceledNotificationAfterDefaultNetworkLost() { onDefaultNetworkLost(TEST_NETWORK) mLooper.processAllMessages() verify(mNm).cancel(TEST_NETWORK_TAG, mNoteIdCaptor.value) } @Test fun testConnectedNotification_NoSsid() { onCapabilitiesChanged(EMPTY_CAPABILITIES) mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK) onCapabilitiesChanged(VALIDATED_CAPABILITIES) mLooper.processAllMessages() // There is no notification when SSID is not set. verify(mNm, never()).notify(any(), anyInt(), any()) } @Test fun testConnectedNotification_WithSsid() { val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES).setSSID(TEST_SSID) onCapabilitiesChanged(EMPTY_CAPABILITIES) mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK) onCapabilitiesChanged(capabilities) mLooper.processAllMessages() verifyConnectedNotification() verify(mResources).getString(R.string.connected) verifyWifiSettingsIntent(mIntentCaptor.value) verifyCanceledNotificationAfterNetworkLost() } @Test fun testConnectedVenueInfoNotification() { mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK) onLinkPropertiesChanged(mTestCapportLp) onDefaultNetworkAvailable(TEST_NETWORK) val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES).setSSID(TEST_SSID) onCapabilitiesChanged(capabilities) mLooper.processAllMessages() verifyConnectedNotification(timeout = 0) verifyVenueInfoIntent(mIntentCaptor.value) verify(mResources).getString(R.string.tap_for_info) verifyCanceledNotificationAfterDefaultNetworkLost() } @Test fun testConnectedVenueInfoNotification_VenueInfoDisabled() { val channel = NotificationChannel(CHANNEL_VENUE_INFO, "test channel", IMPORTANCE_NONE) doReturn(channel).`when`(mNotificationChannelsNm).getNotificationChannel(CHANNEL_VENUE_INFO) mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK) onLinkPropertiesChanged(mTestCapportLp) onDefaultNetworkAvailable(TEST_NETWORK) val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES).setSSID(TEST_SSID) onCapabilitiesChanged(capabilities) mLooper.processAllMessages() verifyConnectedNotification() verifyWifiSettingsIntent(mIntentCaptor.value) verify(mResources, never()).getString(R.string.tap_for_info) verifyCanceledNotificationAfterNetworkLost() } @Test fun testVenueInfoNotification() { onLinkPropertiesChanged(mTestCapportLp) onDefaultNetworkAvailable(TEST_NETWORK) val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES).setSSID(TEST_SSID) onCapabilitiesChanged(capabilities) mLooper.processAllMessages() verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture()) verify(mDependencies).getActivityPendingIntent( eq(mCurrentUserContext), mIntentCaptor.capture(), intThat { it or FLAG_IMMUTABLE != 0 }) verifyVenueInfoIntent(mIntentCaptor.value) verifyCanceledNotificationAfterDefaultNetworkLost() } @Test fun testVenueInfoNotification_VenueInfoDisabled() { doReturn(null).`when`(mNm).getNotificationChannel(CHANNEL_VENUE_INFO) onLinkPropertiesChanged(mTestCapportLp) onDefaultNetworkAvailable(TEST_NETWORK) onCapabilitiesChanged(VALIDATED_CAPABILITIES) mLooper.processAllMessages() verify(mNm, never()).notify(any(), anyInt(), any()) } @Test fun testNonDefaultVenueInfoNotification() { onLinkPropertiesChanged(mTestCapportLp) onCapabilitiesChanged(VALIDATED_CAPABILITIES) mLooper.processAllMessages() verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any()) } @Test fun testEmptyCaptivePortalDataVenueInfoNotification() { onLinkPropertiesChanged(EMPTY_CAPPORT_LP) onCapabilitiesChanged(VALIDATED_CAPABILITIES) mLooper.processAllMessages() verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any()) } @Test fun testUnvalidatedNetworkVenueInfoNotification() { onLinkPropertiesChanged(mTestCapportLp) onCapabilitiesChanged(EMPTY_CAPABILITIES) mLooper.processAllMessages() verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any()) } @Test fun testConnectedVenueInfoWithFriendlyNameNotification() { // Venue info (CaptivePortalData) with friendly name is not available for API <= R assumeTrue(isAtLeastS()) mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK) onLinkPropertiesChanged(mTestCapportVenueUrlWithFriendlyNameLp) onDefaultNetworkAvailable(TEST_NETWORK) val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES).setSSID(TEST_SSID) onCapabilitiesChanged(capabilities) mLooper.processAllMessages() verifyConnectedNotification(timeout = 0) verifyVenueInfoIntent(mIntentCaptor.value) verify(mResources).getString(R.string.tap_for_info) verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture()) val note = mNoteCaptor.value assertEquals(TEST_NETWORK_FRIENDLY_NAME, note.extras .getCharSequence(Notification.EXTRA_TITLE)) verifyCanceledNotificationAfterDefaultNetworkLost() } private fun verifyVenueInfoIntent(intent: Intent) { assertEquals(Intent.ACTION_VIEW, intent.action) assertEquals(Uri.parse(TEST_VENUE_INFO_URL), intent.data) assertEquals(TEST_NETWORK, intent.getParcelableExtra(EXTRA_NETWORK)) } private fun verifyWifiSettingsIntent(intent: Intent) { assertEquals(Settings.ACTION_WIFI_SETTINGS, intent.action) } private fun onDefaultNetworkAvailable(network: Network) { mHandler.post { mDefaultNetworkCb.onAvailable(network) } } private fun onDefaultNetworkLost(network: Network) { mHandler.post { mDefaultNetworkCb.onLost(network) } } private fun onCapabilitiesChanged(capabilities: NetworkCapabilities) { mHandler.post { mAllNetworksCb.onCapabilitiesChanged(TEST_NETWORK, capabilities) } } private fun onLinkPropertiesChanged(lp: LinkProperties) { mHandler.post { mAllNetworksCb.onLinkPropertiesChanged(TEST_NETWORK, lp) } } private fun onLost(network: Network) { mHandler.post { mAllNetworksCb.onLost(network) } } }