From 57e557fd182cfb82a84b88c5a3ee6621cacbe4b1 Mon Sep 17 00:00:00 2001 From: Tiger Oakes Date: Fri, 14 Aug 2020 16:08:41 -0700 Subject: [PATCH] Use AC version of PrivateNotificationService (#12459) --- app/src/main/AndroidManifest.xml | 2 +- .../java/org/mozilla/fenix/HomeActivity.kt | 13 +- .../session/NotificationSessionObserver.kt | 51 ----- .../session/PrivateNotificationService.kt | 66 +++++++ .../session/SessionNotificationService.kt | 174 ------------------ .../NotificationSessionObserverTest.kt | 87 --------- .../session/PrivateNotificationServiceTest.kt | 64 +++++++ .../session/SessionNotificationServiceTest.kt | 27 --- 8 files changed, 140 insertions(+), 344 deletions(-) delete mode 100644 app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt create mode 100644 app/src/main/java/org/mozilla/fenix/session/PrivateNotificationService.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt delete mode 100644 app/src/test/java/org/mozilla/fenix/session/NotificationSessionObserverTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt delete mode 100644 app/src/test/java/org/mozilla/fenix/session/SessionNotificationServiceTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb20352e0..825889326 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -260,7 +260,7 @@ android:resource="@xml/search_widget_info" /> - ? = null private var isToolbarInflated = false @@ -174,7 +175,11 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { sessionObserver = UriOpenedObserver(this) checkPrivateShortcutEntryPoint(intent) - privateNotificationObserver = NotificationSessionObserver(applicationContext).also { + privateNotificationObserver = PrivateNotificationFeature( + applicationContext, + components.core.store, + PrivateNotificationService::class + ).also { it.start() } @@ -479,7 +484,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { intent.getStringExtra(OPEN_TO_SEARCH) == StartSearchIntentProcessor.PRIVATE_BROWSING_PINNED_SHORTCUT) ) { - NotificationSessionObserver.isStartedFromPrivateShortcut = true + PrivateNotificationService.isStartedFromPrivateShortcut = true } } diff --git a/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt b/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt deleted file mode 100644 index 7b6f49410..000000000 --- a/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.session - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map -import mozilla.components.browser.state.selector.privateTabs -import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged -import org.mozilla.fenix.ext.components - -/** - * This observer starts and stops the service to show a notification - * indicating that a private tab is open. - */ -class NotificationSessionObserver( - private val applicationContext: Context, - private val notificationService: SessionNotificationService.Companion = SessionNotificationService -) { - - private var scope: CoroutineScope? = null - - @ExperimentalCoroutinesApi - fun start() { - scope = applicationContext.components.core.store.flowScoped { flow -> - flow.map { state -> state.privateTabs.isNotEmpty() } - .ifChanged() - .collect { hasPrivateTabs -> - if (hasPrivateTabs) { - notificationService.start(applicationContext, isStartedFromPrivateShortcut) - } else if (SessionNotificationService.started) { - notificationService.stop(applicationContext) - } - } - } - } - - fun stop() { - scope?.cancel() - } - - companion object { - var isStartedFromPrivateShortcut = false - } -} diff --git a/app/src/main/java/org/mozilla/fenix/session/PrivateNotificationService.kt b/app/src/main/java/org/mozilla/fenix/session/PrivateNotificationService.kt new file mode 100644 index 000000000..d38101c76 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/session/PrivateNotificationService.kt @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.session + +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.privatemode.notification.AbstractPrivateNotificationService +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics + +/** + * Manages notifications for private tabs. + * + * Private tab notifications solve two problems for us: + * 1 - They allow users to interact with us from outside of the app (example: by closing all + * private tabs). + * 2 - The notification will keep our process alive, allowing us to keep private tabs in memory. + * + * As long as a session is active this service will keep its notification alive. + */ +class PrivateNotificationService : AbstractPrivateNotificationService() { + + override val store: BrowserStore by lazy { components.core.store } + + override fun NotificationCompat.Builder.buildNotification() { + setSmallIcon(R.drawable.ic_pbm_notification) + setContentTitle(getString(R.string.app_name_private_4, getString(R.string.app_name))) + setContentText(getString(R.string.notification_pbm_delete_text_2)) + color = ContextCompat.getColor(this@PrivateNotificationService, R.color.pbm_notification_color) + } + + override fun erasePrivateTabs() { + metrics.track(Event.PrivateBrowsingNotificationTapped) + + val homeScreenIntent = Intent(this, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(HomeActivity.PRIVATE_BROWSING_MODE, isStartedFromPrivateShortcut) + } + + if (VisibilityLifecycleCallback.finishAndRemoveTaskIfInBackground(this)) { + // Set start mode to be in background (recents screen) + homeScreenIntent.apply { + putExtra(HomeActivity.START_IN_RECENTS_SCREEN, true) + } + } + + startActivity(homeScreenIntent) + super.erasePrivateTabs() + } + + companion object { + + /** + * Global used by [HomeActivity] to figure out if normal mode or private mode + * should be used after closing all private tabs. + */ + var isStartedFromPrivateShortcut = false + } +} diff --git a/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt b/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt deleted file mode 100644 index e85411b20..000000000 --- a/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.session - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import mozilla.components.browser.session.SessionManager -import mozilla.components.support.utils.ThreadUtils -import org.mozilla.fenix.HomeActivity -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.metrics -import org.mozilla.fenix.ext.sessionsOfType - -/** - * Manages notifications for private tabs. - * - * Private tab notifications solve two problems for us: - * 1 - They allow users to interact with us from outside of the app (example: by closing all - * private tabs). - * 2 - The notification will keep our process alive, allowing us to keep private tabs in memory. - * - * As long as a session is active this service will keep its notification alive. - */ -class SessionNotificationService : Service() { - - private var isStartedFromPrivateShortcut: Boolean = false - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - val action = intent.action ?: return START_NOT_STICKY - - when (action) { - ACTION_START -> { - isStartedFromPrivateShortcut = intent.getBooleanExtra(STARTED_FROM_PRIVATE_SHORTCUT, false) - createNotificationChannelIfNeeded() - startForeground(NOTIFICATION_ID, buildNotification()) - } - - ACTION_ERASE -> { - metrics.track(Event.PrivateBrowsingNotificationTapped) - - val homeScreenIntent = Intent(this, HomeActivity::class.java) - val intentFlags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - homeScreenIntent.apply { - setFlags(intentFlags) - putExtra(HomeActivity.PRIVATE_BROWSING_MODE, isStartedFromPrivateShortcut) - } - if (VisibilityLifecycleCallback.finishAndRemoveTaskIfInBackground(this)) { - // Set start mode to be in background (recents screen) - homeScreenIntent.apply { - putExtra(HomeActivity.START_IN_RECENTS_SCREEN, true) - } - } - startActivity(homeScreenIntent) - components.core.sessionManager.removeAndCloseAllPrivateSessions() - } - - else -> throw IllegalStateException("Unknown intent: $intent") - } - - return START_NOT_STICKY - } - - override fun onTaskRemoved(rootIntent: Intent) { - components.core.sessionManager.removeAndCloseAllPrivateSessions() - - stopForeground(true) - stopSelf() - } - - private fun buildNotification(): Notification { - return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_pbm_notification) - .setContentTitle(getString(R.string.app_name_private_4, getString(R.string.app_name))) - .setContentText(getString(R.string.notification_pbm_delete_text_2)) - .setContentIntent(createNotificationIntent()) - .setVisibility(NotificationCompat.VISIBILITY_SECRET) - .setShowWhen(false) - .setLocalOnly(true) - .setColor(ContextCompat.getColor(this, R.color.pbm_notification_color)) - .build() - } - - private fun createNotificationIntent(): PendingIntent { - val intent = Intent(this, SessionNotificationService::class.java) - intent.action = ACTION_ERASE - - return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_ONE_SHOT) - } - - private fun createNotificationChannelIfNeeded() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - // Notification channels are only available on Android O or higher. - return - } - - val notificationManager = getSystemService() ?: return - - val notificationChannelName = getString(R.string.notification_pbm_channel_name) - - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, notificationChannelName, NotificationManager.IMPORTANCE_MIN - ) - channel.importance = NotificationManager.IMPORTANCE_LOW - channel.enableLights(false) - channel.enableVibration(false) - channel.setShowBadge(false) - - notificationManager.createNotificationChannel(channel) - } - - private fun SessionManager.removeAndCloseAllPrivateSessions() { - sessionsOfType(private = true).forEach { remove(it) } - } - - override fun onBind(intent: Intent): IBinder? { - return null - } - - companion object { - private const val NOTIFICATION_ID = 83 - private const val NOTIFICATION_CHANNEL_ID = "browsing-session" - private const val STARTED_FROM_PRIVATE_SHORTCUT = "STARTED_FROM_PRIVATE_SHORTCUT" - - private const val ACTION_START = "start" - private const val ACTION_ERASE = "erase" - internal var started = false - - internal fun start( - context: Context, - startedFromPrivateShortcut: Boolean - ) { - val intent = Intent(context, SessionNotificationService::class.java) - intent.action = ACTION_START - intent.putExtra(STARTED_FROM_PRIVATE_SHORTCUT, startedFromPrivateShortcut) - - // From Focus #2901: The application is crashing due to the service not calling `startForeground` - // before it times out. This is a speculative fix to decrease the time between these two - // calls by running this after potentially expensive calls in FocusApplication.onCreate and - // BrowserFragment.inflateView by posting it to the end of the main thread. - ThreadUtils.postToMainThread(Runnable { - context.startService(intent) - }) - - started = true - } - - internal fun stop(context: Context) { - val intent = Intent(context, SessionNotificationService::class.java) - - // We want to make sure we always call stop after start. So we're - // putting these actions on the same sequential run queue. - ThreadUtils.postToMainThread(Runnable { - context.stopService(intent) - }) - - started = false - } - } -} diff --git a/app/src/test/java/org/mozilla/fenix/session/NotificationSessionObserverTest.kt b/app/src/test/java/org/mozilla/fenix/session/NotificationSessionObserverTest.kt deleted file mode 100644 index 87d462ecf..000000000 --- a/app/src/test/java/org/mozilla/fenix/session/NotificationSessionObserverTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.session - -import android.content.Context -import io.mockk.Called -import io.mockk.MockKAnnotations -import io.mockk.confirmVerified -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import mozilla.components.browser.state.action.CustomTabListAction -import mozilla.components.browser.state.action.TabListAction -import mozilla.components.browser.state.state.createCustomTab -import mozilla.components.browser.state.state.createTab -import mozilla.components.browser.state.store.BrowserStore -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.fenix.ext.components -import org.mozilla.fenix.helpers.FenixRobolectricTestRunner - -@ExperimentalCoroutinesApi -@RunWith(FenixRobolectricTestRunner::class) -class NotificationSessionObserverTest { - - private lateinit var observer: NotificationSessionObserver - private lateinit var store: BrowserStore - @MockK private lateinit var context: Context - @MockK(relaxed = true) private lateinit var notificationService: SessionNotificationService.Companion - - @Before - fun before() { - MockKAnnotations.init(this) - store = BrowserStore() - every { context.components.core.store } returns store - observer = NotificationSessionObserver(context, notificationService) - NotificationSessionObserver.isStartedFromPrivateShortcut = false - } - - @Test - fun `GIVEN session is private and non-custom WHEN it is added THEN notification service should be started`() = runBlocking { - val privateSession = createTab("https://firefox.com", private = true) - - store.dispatch(TabListAction.AddTabAction(privateSession)).join() - - observer.start() - verify(exactly = 1) { notificationService.start(context, false) } - confirmVerified(notificationService) - } - - @Test - fun `GIVEN session is not private WHEN it is added THEN notification service should not be started`() = runBlocking { - val normalSession = createTab("https://firefox.com") - val customSession = createCustomTab("https://firefox.com") - - observer.start() - verify { notificationService wasNot Called } - - store.dispatch(TabListAction.AddTabAction(normalSession)).join() - verify(exactly = 0) { notificationService.start(context, false) } - - store.dispatch(CustomTabListAction.AddCustomTabAction(customSession)).join() - verify(exactly = 0) { notificationService.start(context, false) } - } - - @Test - fun `GIVEN session is custom tab WHEN it is added THEN notification service should not be started`() = runBlocking { - val privateCustomSession = createCustomTab("https://firefox.com").let { - it.copy(content = it.content.copy(private = true)) - } - val customSession = createCustomTab("https://firefox.com") - - observer.start() - verify { notificationService wasNot Called } - - store.dispatch(CustomTabListAction.AddCustomTabAction(privateCustomSession)).join() - verify(exactly = 0) { notificationService.start(context, false) } - - store.dispatch(CustomTabListAction.AddCustomTabAction(customSession)).join() - verify(exactly = 0) { notificationService.start(context, false) } - } -} diff --git a/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt b/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt new file mode 100644 index 000000000..d0267c53d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/session/PrivateNotificationServiceTest.kt @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.session + +import android.content.ComponentName +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import mozilla.components.feature.privatemode.notification.AbstractPrivateNotificationService.Companion.ACTION_ERASE +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.HomeActivity.Companion.PRIVATE_BROWSING_MODE +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController + +@RunWith(FenixRobolectricTestRunner::class) +class PrivateNotificationServiceTest { + + private lateinit var controller: ServiceController + + @Before + fun setup() { + val store = testContext.components.core.store + every { store.dispatch(any()) } returns mockk() + + controller = Robolectric.buildService( + PrivateNotificationService::class.java, + Intent(ACTION_ERASE) + ) + } + + @Test + fun `service opens home activity with PBM flag set to true`() { + PrivateNotificationService.isStartedFromPrivateShortcut = true + val service = shadowOf(controller.get()) + controller.startCommand(0, 0) + + val intent = service.nextStartedActivity + assertEquals(ComponentName(testContext, HomeActivity::class.java), intent.component) + assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, intent.flags) + assertEquals(true, intent.extras?.getBoolean(PRIVATE_BROWSING_MODE)) + } + + @Test + fun `service opens home activity with PBM flag set to false`() { + PrivateNotificationService.isStartedFromPrivateShortcut = false + val service = shadowOf(controller.get()) + controller.startCommand(0, 0) + + val intent = service.nextStartedActivity + assertEquals(ComponentName(testContext, HomeActivity::class.java), intent.component) + assertEquals(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK, intent.flags) + assertEquals(false, intent.extras?.getBoolean(PRIVATE_BROWSING_MODE)) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/session/SessionNotificationServiceTest.kt b/app/src/test/java/org/mozilla/fenix/session/SessionNotificationServiceTest.kt deleted file mode 100644 index 2a9e5bccd..000000000 --- a/app/src/test/java/org/mozilla/fenix/session/SessionNotificationServiceTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.session - -import mozilla.components.support.test.robolectric.testContext -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.fenix.helpers.FenixRobolectricTestRunner - -@RunWith(FenixRobolectricTestRunner::class) -class SessionNotificationServiceTest { - - @Test - fun `Service keeps tracked of started state`() { - assertFalse(SessionNotificationService.started) - - SessionNotificationService.start(testContext, false) - assertTrue(SessionNotificationService.started) - - SessionNotificationService.stop(testContext) - assertFalse(SessionNotificationService.started) - } -}