diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bb20352e09..8258893266 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 7b6f494106..0000000000
--- 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 0000000000..d38101c763
--- /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 e85411b202..0000000000
--- 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 87d462ecf2..0000000000
--- 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 0000000000..d0267c53d4
--- /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 2a9e5bccd1..0000000000
--- 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)
- }
-}