1
0
Fork 0

Use AC version of PrivateNotificationService (#12459)

master
Tiger Oakes 2020-08-14 16:08:41 -07:00 committed by GitHub
parent da1579b361
commit 57e557fd18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 140 additions and 344 deletions

View File

@ -260,7 +260,7 @@
android:resource="@xml/search_widget_info" />
</receiver>
<service android:name=".session.SessionNotificationService"
<service android:name=".session.PrivateNotificationService"
android:exported="false" />
<service

View File

@ -44,6 +44,7 @@ import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.contextmenu.DefaultSelectionActionDelegate
import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature
import mozilla.components.feature.search.BrowserStoreSearchAdapter
import mozilla.components.feature.search.SearchAdapter
import mozilla.components.service.fxa.sync.SyncReason
@ -88,7 +89,7 @@ import org.mozilla.fenix.perf.Performance
import org.mozilla.fenix.perf.StartupTimeline
import org.mozilla.fenix.search.SearchFragmentDirections
import org.mozilla.fenix.searchdialog.SearchDialogFragmentDirections
import org.mozilla.fenix.session.NotificationSessionObserver
import org.mozilla.fenix.session.PrivateNotificationService
import org.mozilla.fenix.settings.SettingsFragmentDirections
import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections
import org.mozilla.fenix.settings.about.AboutFragmentDirections
@ -121,7 +122,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
private var isVisuallyComplete = false
private var visualCompletenessQueue: RunWhenReadyQueue? = null
private var privateNotificationObserver: NotificationSessionObserver? = null
private var privateNotificationObserver: PrivateNotificationFeature<PrivateNotificationService>? = 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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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) }
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}