[fenix] Use AC version of PrivateNotificationService (https://github.com/mozilla-mobile/fenix/pull/12459)
parent
bdf01c141b
commit
1f7bb1af2e
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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<NotificationManager>() ?: 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
@ -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<PrivateNotificationService>
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue