| /* |
| * 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.tethering |
| |
| import android.app.Notification |
| import android.app.NotificationManager |
| import android.content.Context |
| import android.content.res.Resources |
| import android.net.ConnectivityManager.TETHERING_BLUETOOTH |
| import android.net.ConnectivityManager.TETHERING_USB |
| import android.net.ConnectivityManager.TETHERING_WIFI |
| import android.net.Network |
| import android.os.Handler |
| import android.os.HandlerThread |
| import android.os.Looper |
| import android.os.UserHandle |
| import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID |
| import android.telephony.TelephonyManager |
| import androidx.test.filters.SmallTest |
| import androidx.test.platform.app.InstrumentationRegistry |
| import androidx.test.runner.AndroidJUnit4 |
| import com.android.internal.util.test.BroadcastInterceptingContext |
| import com.android.networkstack.tethering.TetheringNotificationUpdater.DOWNSTREAM_NONE |
| import com.android.networkstack.tethering.TetheringNotificationUpdater.ENABLE_NOTIFICATION_ID |
| import com.android.networkstack.tethering.TetheringNotificationUpdater.EVENT_SHOW_NO_UPSTREAM |
| import com.android.networkstack.tethering.TetheringNotificationUpdater.NO_UPSTREAM_NOTIFICATION_ID |
| import com.android.networkstack.tethering.TetheringNotificationUpdater.RESTRICTED_NOTIFICATION_ID |
| import com.android.testutils.waitForIdle |
| import org.junit.After |
| import org.junit.Assert.assertEquals |
| import org.junit.Assert.fail |
| import org.junit.Before |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| import org.mockito.ArgumentCaptor |
| import org.mockito.ArgumentMatchers.any |
| import org.mockito.ArgumentMatchers.anyInt |
| import org.mockito.ArgumentMatchers.eq |
| import org.mockito.Mock |
| import org.mockito.Mockito.doReturn |
| import org.mockito.Mockito.never |
| import org.mockito.Mockito.reset |
| import org.mockito.Mockito.times |
| import org.mockito.Mockito.verify |
| import org.mockito.Mockito.verifyZeroInteractions |
| import org.mockito.MockitoAnnotations |
| |
| const val TEST_SUBID = 1 |
| const val WIFI_ICON_ID = 1 |
| const val USB_ICON_ID = 2 |
| const val BT_ICON_ID = 3 |
| const val GENERAL_ICON_ID = 4 |
| const val WIFI_MASK = 1 shl TETHERING_WIFI |
| const val USB_MASK = 1 shl TETHERING_USB |
| const val BT_MASK = 1 shl TETHERING_BLUETOOTH |
| const val TITLE = "Tethering active" |
| const val MESSAGE = "Tap here to set up." |
| const val TEST_TITLE = "Hotspot active" |
| const val TEST_MESSAGE = "Tap to set up hotspot." |
| const val TEST_NO_UPSTREAM_TITLE = "Hotspot has no internet access" |
| const val TEST_NO_UPSTREAM_MESSAGE = "Device cannot connect to internet." |
| const val TEST_NO_UPSTREAM_BUTTON = "Turn off hotspot" |
| |
| @RunWith(AndroidJUnit4::class) |
| @SmallTest |
| class TetheringNotificationUpdaterTest { |
| // lateinit used here for mocks as they need to be reinitialized between each test and the test |
| // should crash if they are used before being initialized. |
| @Mock private lateinit var mockContext: Context |
| @Mock private lateinit var notificationManager: NotificationManager |
| @Mock private lateinit var telephonyManager: TelephonyManager |
| @Mock private lateinit var defaultResources: Resources |
| @Mock private lateinit var testResources: Resources |
| |
| // lateinit for these classes under test, as they should be reset to a different instance for |
| // every test but should always be initialized before use (or the test should crash). |
| private lateinit var context: TestContext |
| private lateinit var notificationUpdater: TetheringNotificationUpdater |
| private lateinit var fakeTetheringThread: HandlerThread |
| |
| private val ENABLE_ICON_CONFIGS = arrayOf( |
| "USB;android.test:drawable/usb", "BT;android.test:drawable/bluetooth", |
| "WIFI|BT;android.test:drawable/general", "WIFI|USB;android.test:drawable/general", |
| "USB|BT;android.test:drawable/general", "WIFI|USB|BT;android.test:drawable/general") |
| |
| private inner class TestContext(c: Context) : BroadcastInterceptingContext(c) { |
| override fun createContextAsUser(user: UserHandle, flags: Int) = |
| if (user == UserHandle.ALL) mockContext else this |
| override fun getSystemService(name: String) = |
| if (name == Context.TELEPHONY_SERVICE) telephonyManager |
| else super.getSystemService(name) |
| } |
| |
| private inner class WrappedNotificationUpdater(c: Context, looper: Looper) |
| : TetheringNotificationUpdater(c, looper) { |
| override fun getResourcesForSubId(context: Context, subId: Int) = |
| when (subId) { |
| TEST_SUBID -> testResources |
| INVALID_SUBSCRIPTION_ID -> defaultResources |
| else -> super.getResourcesForSubId(context, subId) |
| } |
| } |
| |
| private fun setupResources() { |
| doReturn(ENABLE_ICON_CONFIGS).`when`(defaultResources) |
| .getStringArray(R.array.tethering_notification_icons) |
| doReturn(arrayOf("WIFI;android.test:drawable/wifi")).`when`(testResources) |
| .getStringArray(R.array.tethering_notification_icons) |
| doReturn(5).`when`(testResources) |
| .getInteger(R.integer.delay_to_show_no_upstream_after_no_backhaul) |
| doReturn(TITLE).`when`(defaultResources).getString(R.string.tethering_notification_title) |
| doReturn(MESSAGE).`when`(defaultResources) |
| .getString(R.string.tethering_notification_message) |
| doReturn(TEST_TITLE).`when`(testResources).getString(R.string.tethering_notification_title) |
| doReturn(TEST_MESSAGE).`when`(testResources) |
| .getString(R.string.tethering_notification_message) |
| doReturn(TEST_NO_UPSTREAM_TITLE).`when`(testResources) |
| .getString(R.string.no_upstream_notification_title) |
| doReturn(TEST_NO_UPSTREAM_MESSAGE).`when`(testResources) |
| .getString(R.string.no_upstream_notification_message) |
| doReturn(TEST_NO_UPSTREAM_BUTTON).`when`(testResources) |
| .getString(R.string.no_upstream_notification_disable_button) |
| doReturn(USB_ICON_ID).`when`(defaultResources) |
| .getIdentifier(eq("android.test:drawable/usb"), any(), any()) |
| doReturn(BT_ICON_ID).`when`(defaultResources) |
| .getIdentifier(eq("android.test:drawable/bluetooth"), any(), any()) |
| doReturn(GENERAL_ICON_ID).`when`(defaultResources) |
| .getIdentifier(eq("android.test:drawable/general"), any(), any()) |
| doReturn(WIFI_ICON_ID).`when`(testResources) |
| .getIdentifier(eq("android.test:drawable/wifi"), any(), any()) |
| } |
| |
| @Before |
| fun setUp() { |
| MockitoAnnotations.initMocks(this) |
| context = TestContext(InstrumentationRegistry.getInstrumentation().context) |
| doReturn(notificationManager).`when`(mockContext) |
| .getSystemService(Context.NOTIFICATION_SERVICE) |
| fakeTetheringThread = HandlerThread(this::class.simpleName) |
| fakeTetheringThread.start() |
| notificationUpdater = WrappedNotificationUpdater(context, fakeTetheringThread.looper) |
| setupResources() |
| } |
| |
| @After |
| fun tearDown() { |
| fakeTetheringThread.quitSafely() |
| } |
| |
| private fun Notification.title() = this.extras.getString(Notification.EXTRA_TITLE) |
| private fun Notification.text() = this.extras.getString(Notification.EXTRA_TEXT) |
| |
| private fun verifyNotification(iconId: Int, title: String, text: String, id: Int) { |
| verify(notificationManager, never()).cancel(any(), eq(id)) |
| |
| val notificationCaptor = ArgumentCaptor.forClass(Notification::class.java) |
| verify(notificationManager, times(1)) |
| .notify(any(), eq(id), notificationCaptor.capture()) |
| |
| val notification = notificationCaptor.getValue() |
| assertEquals(iconId, notification.smallIcon.resId) |
| assertEquals(title, notification.title()) |
| assertEquals(text, notification.text()) |
| } |
| |
| private fun verifyNotificationCancelled(id: Int) = |
| verify(notificationManager, times(1)).cancel(any(), eq(id)) |
| |
| private val tetheringActiveNotifications = |
| listOf(NO_UPSTREAM_NOTIFICATION_ID, ENABLE_NOTIFICATION_ID) |
| |
| private fun verifyCancelAllTetheringActiveNotifications() { |
| tetheringActiveNotifications.forEach { |
| verifyNotificationCancelled(it) |
| } |
| reset(notificationManager) |
| } |
| |
| private fun verifyOnlyTetheringActiveNotification( |
| notifyId: Int, |
| iconId: Int, |
| title: String, |
| text: String |
| ) { |
| tetheringActiveNotifications.forEach { |
| when (it) { |
| notifyId -> verifyNotification(iconId, title, text, notifyId) |
| else -> verifyNotificationCancelled(it) |
| } |
| } |
| reset(notificationManager) |
| } |
| |
| @Test |
| fun testNotificationWithDownstreamChanged() { |
| // Wifi downstream. No notification. |
| notificationUpdater.onDownstreamChanged(WIFI_MASK) |
| verifyCancelAllTetheringActiveNotifications() |
| |
| // Same downstream changed. Nothing happened. |
| notificationUpdater.onDownstreamChanged(WIFI_MASK) |
| verifyZeroInteractions(notificationManager) |
| |
| // Wifi and usb downstreams. Show enable notification |
| notificationUpdater.onDownstreamChanged(WIFI_MASK or USB_MASK) |
| verifyOnlyTetheringActiveNotification( |
| ENABLE_NOTIFICATION_ID, GENERAL_ICON_ID, TITLE, MESSAGE) |
| |
| // Usb downstream. Still show enable notification. |
| notificationUpdater.onDownstreamChanged(USB_MASK) |
| verifyOnlyTetheringActiveNotification(ENABLE_NOTIFICATION_ID, USB_ICON_ID, TITLE, MESSAGE) |
| |
| // No downstream. No notification. |
| notificationUpdater.onDownstreamChanged(DOWNSTREAM_NONE) |
| verifyCancelAllTetheringActiveNotifications() |
| } |
| |
| @Test |
| fun testNotificationWithActiveDataSubscriptionIdChanged() { |
| // Usb downstream. Showed enable notification with default resource. |
| notificationUpdater.onDownstreamChanged(USB_MASK) |
| verifyOnlyTetheringActiveNotification(ENABLE_NOTIFICATION_ID, USB_ICON_ID, TITLE, MESSAGE) |
| |
| // Same subId changed. Nothing happened. |
| notificationUpdater.onActiveDataSubscriptionIdChanged(INVALID_SUBSCRIPTION_ID) |
| verifyZeroInteractions(notificationManager) |
| |
| // Set test sub id. Clear notification with test resource. |
| notificationUpdater.onActiveDataSubscriptionIdChanged(TEST_SUBID) |
| verifyCancelAllTetheringActiveNotifications() |
| |
| // Wifi downstream. Show enable notification with test resource. |
| notificationUpdater.onDownstreamChanged(WIFI_MASK) |
| verifyOnlyTetheringActiveNotification( |
| ENABLE_NOTIFICATION_ID, WIFI_ICON_ID, TEST_TITLE, TEST_MESSAGE) |
| |
| // No downstream. No notification. |
| notificationUpdater.onDownstreamChanged(DOWNSTREAM_NONE) |
| verifyCancelAllTetheringActiveNotifications() |
| } |
| |
| private fun assertIconNumbers(number: Int, configs: Array<String?>) { |
| doReturn(configs).`when`(defaultResources) |
| .getStringArray(R.array.tethering_notification_icons) |
| assertEquals(number, notificationUpdater.getIcons( |
| R.array.tethering_notification_icons, defaultResources).size()) |
| } |
| |
| @Test |
| fun testGetIcons() { |
| assertIconNumbers(0, arrayOfNulls<String>(0)) |
| assertIconNumbers(0, arrayOf(null, "")) |
| assertIconNumbers(3, arrayOf( |
| // These configurations are invalid with wrong strings or symbols. |
| ";", ",", "|", "|,;", "WIFI", "1;2", " U SB; ", "bt;", "WIFI;USB;BT", "WIFI|USB|BT", |
| "WIFI,BT,USB", " WIFI| | | USB, test:drawable/test", |
| // This configuration is valid with two downstream types (USB, BT). |
| "USB|,,,,,|BT;drawable/test ", |
| // This configuration is valid with one downstream types (WIFI). |
| " WIFI ; android.test:drawable/xxx ")) |
| } |
| |
| @Test |
| fun testGetDownstreamTypesMask() { |
| assertEquals(DOWNSTREAM_NONE, notificationUpdater.getDownstreamTypesMask("")) |
| assertEquals(DOWNSTREAM_NONE, notificationUpdater.getDownstreamTypesMask("1")) |
| assertEquals(DOWNSTREAM_NONE, notificationUpdater.getDownstreamTypesMask("WIFI_P2P")) |
| assertEquals(DOWNSTREAM_NONE, notificationUpdater.getDownstreamTypesMask("usb")) |
| assertEquals(WIFI_MASK, notificationUpdater.getDownstreamTypesMask(" WIFI ")) |
| assertEquals(USB_MASK, notificationUpdater.getDownstreamTypesMask("USB | B T")) |
| assertEquals(BT_MASK, notificationUpdater.getDownstreamTypesMask(" WIFI: | BT")) |
| assertEquals(WIFI_MASK or USB_MASK, |
| notificationUpdater.getDownstreamTypesMask("1|2|USB|WIFI|BLUETOOTH||")) |
| } |
| |
| @Test |
| fun testSetupRestrictedNotification() { |
| val title = context.resources.getString(R.string.disable_tether_notification_title) |
| val message = context.resources.getString(R.string.disable_tether_notification_message) |
| val disallowTitle = "Tether function is disallowed" |
| val disallowMessage = "Please contact your admin" |
| doReturn(title).`when`(defaultResources) |
| .getString(R.string.disable_tether_notification_title) |
| doReturn(message).`when`(defaultResources) |
| .getString(R.string.disable_tether_notification_message) |
| doReturn(disallowTitle).`when`(testResources) |
| .getString(R.string.disable_tether_notification_title) |
| doReturn(disallowMessage).`when`(testResources) |
| .getString(R.string.disable_tether_notification_message) |
| |
| // User restrictions on. Show restricted notification. |
| notificationUpdater.notifyTetheringDisabledByRestriction() |
| verifyNotification(R.drawable.stat_sys_tether_general, title, message, |
| RESTRICTED_NOTIFICATION_ID) |
| reset(notificationManager) |
| |
| // User restrictions off. Clear notification. |
| notificationUpdater.tetheringRestrictionLifted() |
| verifyNotificationCancelled(RESTRICTED_NOTIFICATION_ID) |
| reset(notificationManager) |
| |
| // Set test sub id. No notification. |
| notificationUpdater.onActiveDataSubscriptionIdChanged(TEST_SUBID) |
| verifyCancelAllTetheringActiveNotifications() |
| |
| // User restrictions on again. Show restricted notification with test resource. |
| notificationUpdater.notifyTetheringDisabledByRestriction() |
| verifyNotification(R.drawable.stat_sys_tether_general, disallowTitle, disallowMessage, |
| RESTRICTED_NOTIFICATION_ID) |
| reset(notificationManager) |
| } |
| |
| val MAX_BACKOFF_MS = 200L |
| /** |
| * Waits for all messages, including delayed ones, to be processed. |
| * |
| * This will wait until the handler has no more messages to be processed including |
| * delayed ones, or the timeout has expired. It uses an exponential backoff strategy |
| * to wait longer and longer to consume less CPU, with the max granularity being |
| * MAX_BACKOFF_MS. |
| * |
| * @return true if all messages have been processed including delayed ones, false if timeout |
| * |
| * TODO: Move this method to com.android.testutils.HandlerUtils.kt. |
| */ |
| private fun Handler.waitForDelayedMessage(what: Int?, timeoutMs: Long) { |
| fun hasMatchingMessages() = |
| if (what == null) hasMessagesOrCallbacks() else hasMessages(what) |
| val expiry = System.currentTimeMillis() + timeoutMs |
| var delay = 5L |
| while (System.currentTimeMillis() < expiry && hasMatchingMessages()) { |
| // None of Handler, Looper, Message and MessageQueue expose any way to retrieve |
| // the time when the next (let alone the last) message will be processed, so |
| // short of examining the internals with reflection sleep() is the only solution. |
| Thread.sleep(delay) |
| delay = (delay * 2) |
| .coerceAtMost(expiry - System.currentTimeMillis()) |
| .coerceAtMost(MAX_BACKOFF_MS) |
| } |
| |
| val timeout = expiry - System.currentTimeMillis() |
| if (timeout <= 0) fail("Delayed message did not process yet after ${timeoutMs}ms") |
| waitForIdle(timeout) |
| } |
| |
| @Test |
| fun testNotificationWithUpstreamNetworkChanged() { |
| // Set test sub id. No notification. |
| notificationUpdater.onActiveDataSubscriptionIdChanged(TEST_SUBID) |
| verifyCancelAllTetheringActiveNotifications() |
| |
| // Wifi downstream. Show enable notification with test resource. |
| notificationUpdater.onDownstreamChanged(WIFI_MASK) |
| verifyOnlyTetheringActiveNotification( |
| ENABLE_NOTIFICATION_ID, WIFI_ICON_ID, TEST_TITLE, TEST_MESSAGE) |
| |
| // There is no upstream. Show no upstream notification. |
| notificationUpdater.onUpstreamNetworkChanged(null) |
| notificationUpdater.handler.waitForDelayedMessage(EVENT_SHOW_NO_UPSTREAM, 500L) |
| verifyNotification(R.drawable.stat_sys_tether_general, TEST_NO_UPSTREAM_TITLE, |
| TEST_NO_UPSTREAM_MESSAGE, NO_UPSTREAM_NOTIFICATION_ID) |
| reset(notificationManager) |
| |
| // Same upstream network changed. Nothing happened. |
| notificationUpdater.onUpstreamNetworkChanged(null) |
| verifyZeroInteractions(notificationManager) |
| |
| // Upstream come back. Clear no upstream notification. |
| notificationUpdater.onUpstreamNetworkChanged(Network(1000)) |
| verifyNotificationCancelled(NO_UPSTREAM_NOTIFICATION_ID) |
| reset(notificationManager) |
| |
| // No upstream again. Show no upstream notification. |
| notificationUpdater.onUpstreamNetworkChanged(null) |
| notificationUpdater.handler.waitForDelayedMessage(EVENT_SHOW_NO_UPSTREAM, 500L) |
| verifyNotification(R.drawable.stat_sys_tether_general, TEST_NO_UPSTREAM_TITLE, |
| TEST_NO_UPSTREAM_MESSAGE, NO_UPSTREAM_NOTIFICATION_ID) |
| reset(notificationManager) |
| |
| // No downstream. No notification. |
| notificationUpdater.onDownstreamChanged(DOWNSTREAM_NONE) |
| verifyCancelAllTetheringActiveNotifications() |
| |
| // Set R.integer.delay_to_show_no_upstream_after_no_backhaul to 0 and have wifi downstream |
| // again. Show enable notification only. |
| doReturn(-1).`when`(testResources) |
| .getInteger(R.integer.delay_to_show_no_upstream_after_no_backhaul) |
| notificationUpdater.onDownstreamChanged(WIFI_MASK) |
| notificationUpdater.handler.waitForDelayedMessage(EVENT_SHOW_NO_UPSTREAM, 500L) |
| verifyOnlyTetheringActiveNotification( |
| ENABLE_NOTIFICATION_ID, WIFI_ICON_ID, TEST_TITLE, TEST_MESSAGE) |
| } |
| |
| @Test |
| fun testGetResourcesForSubId() { |
| doReturn(telephonyManager).`when`(telephonyManager).createForSubscriptionId(anyInt()) |
| doReturn(1234).`when`(telephonyManager).getSimCarrierId() |
| doReturn("000000").`when`(telephonyManager).getSimOperator() |
| |
| val subId = -2 // Use invalid subId to avoid getting resource from cache or real subId. |
| val config = context.resources.configuration |
| var res = notificationUpdater.getResourcesForSubId(context, subId) |
| assertEquals(config.mcc, res.configuration.mcc) |
| assertEquals(config.mnc, res.configuration.mnc) |
| |
| doReturn(1839).`when`(telephonyManager).getSimCarrierId() |
| res = notificationUpdater.getResourcesForSubId(context, subId) |
| assertEquals(config.mcc, res.configuration.mcc) |
| assertEquals(config.mnc, res.configuration.mnc) |
| |
| doReturn("20404").`when`(telephonyManager).getSimOperator() |
| res = notificationUpdater.getResourcesForSubId(context, subId) |
| assertEquals(311, res.configuration.mcc) |
| assertEquals(480, res.configuration.mnc) |
| } |
| } |