diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 47a22ca78b..1373ba7e32 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -149,6 +149,9 @@
android:resource="@xml/search_widget_info" />
+
+
diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
index caa455da5e..5e3b99fac6 100644
--- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
+++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
@@ -10,6 +10,7 @@ import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.getSystemService
import io.reactivex.plugins.RxJavaPlugins
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
@@ -31,6 +32,8 @@ import mozilla.components.support.rusthttp.RustHttpConfig
import mozilla.components.support.rustlog.RustLog
import org.mozilla.fenix.GleanMetrics.ExperimentsMetrics
import org.mozilla.fenix.components.Components
+import org.mozilla.fenix.session.NotificationSessionObserver
+import org.mozilla.fenix.session.VisibilityLifecycleCallback
import org.mozilla.fenix.utils.Settings
import java.io.File
@@ -43,6 +46,9 @@ open class FenixApplication : Application() {
open val components by lazy { Components(this) }
+ var visibilityLifecycleCallback: VisibilityLifecycleCallback? = null
+ private set
+
override fun onCreate() {
super.onCreate()
@@ -99,6 +105,11 @@ open class FenixApplication : Application() {
}
setupPush()
+
+ visibilityLifecycleCallback = VisibilityLifecycleCallback(getSystemService())
+ registerActivityLifecycleCallbacks(visibilityLifecycleCallback)
+
+ components.core.sessionManager.register(NotificationSessionObserver(this))
}
private fun registerRxExceptionHandling() {
diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
index 81e4d5569a..92e57e1879 100644
--- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
+++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt
@@ -46,6 +46,7 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.intent.CrashReporterIntentProcessor
import org.mozilla.fenix.home.intent.DeepLinkIntentProcessor
+import org.mozilla.fenix.home.intent.NotificationsIntentProcessor
import org.mozilla.fenix.home.intent.OpenBrowserIntentProcessor
import org.mozilla.fenix.home.intent.SpeechProcessingIntentProcessor
import org.mozilla.fenix.home.intent.StartSearchIntentProcessor
@@ -72,6 +73,7 @@ open class HomeActivity : AppCompatActivity(), ShareFragment.TabsSharedCallback
private val externalSourceIntentProcessors by lazy {
listOf(
+ NotificationsIntentProcessor(this),
SpeechProcessingIntentProcessor(this),
StartSearchIntentProcessor(components.analytics.metrics),
DeepLinkIntentProcessor(this),
@@ -337,5 +339,6 @@ open class HomeActivity : AppCompatActivity(), ShareFragment.TabsSharedCallback
const val OPEN_TO_BROWSER = "open_to_browser"
const val OPEN_TO_BROWSER_AND_LOAD = "open_to_browser_and_load"
const val OPEN_TO_SEARCH = "open_to_search"
+ const val EXTRA_DELETE_PRIVATE_TABS = "notification"
}
}
diff --git a/app/src/main/java/org/mozilla/fenix/home/intent/NotificationsIntentProcessor.kt b/app/src/main/java/org/mozilla/fenix/home/intent/NotificationsIntentProcessor.kt
new file mode 100644
index 0000000000..e9eb5df692
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/home/intent/NotificationsIntentProcessor.kt
@@ -0,0 +1,32 @@
+/* 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.home.intent
+
+import android.content.Intent
+import androidx.navigation.NavController
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.sessionsOfType
+
+/**
+ * The Private Browsing Mode notification has an "Delete and Open" button to let users delete all
+ * of their private tabs.
+ */
+class NotificationsIntentProcessor(
+ private val activity: HomeActivity
+) : HomeIntentProcessor {
+
+ override fun process(intent: Intent, navController: NavController, out: Intent): Boolean {
+ return if (intent.extras?.getBoolean(HomeActivity.EXTRA_DELETE_PRIVATE_TABS) == true) {
+ out.putExtra(HomeActivity.EXTRA_DELETE_PRIVATE_TABS, false)
+ activity.components.core.sessionManager.run {
+ sessionsOfType(private = true).forEach { remove(it) }
+ }
+ true
+ } else {
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt b/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt
new file mode 100644
index 0000000000..090ecf82da
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt
@@ -0,0 +1,39 @@
+/* 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 mozilla.components.browser.session.Session
+import mozilla.components.browser.session.SessionManager
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.sessionsOfType
+
+/**
+ * This observer starts and stops the service to show a notification
+ * indicating that a private tab is open.
+ */
+
+class NotificationSessionObserver(
+ private val context: Context
+) : SessionManager.Observer {
+
+ override fun onSessionRemoved(session: Session) {
+ val privateTabsEmpty = !context.components.core.sessionManager.sessionsOfType(private = true).none()
+
+ if (privateTabsEmpty) {
+ SessionNotificationService.stop(context)
+ }
+ }
+
+ override fun onAllSessionsRemoved() {
+ SessionNotificationService.stop(context)
+ }
+
+ override fun onSessionAdded(session: Session) {
+ if (session.private) {
+ SessionNotificationService.start(context)
+ }
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt b/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt
new file mode 100644
index 0000000000..c59e22e070
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt
@@ -0,0 +1,175 @@
+/* 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.R
+import org.mozilla.fenix.HomeActivity
+import org.mozilla.fenix.ext.components
+import org.mozilla.fenix.ext.sessionsOfType
+
+/**
+ * As long as a session is active this service will keep the notification (and our process) alive.
+ */
+class SessionNotificationService : Service() {
+
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ val action = intent.action ?: return Service.START_NOT_STICKY
+
+ when (action) {
+ ACTION_START -> {
+ createNotificationChannelIfNeeded()
+ startForeground(NOTIFICATION_ID, buildNotification())
+ }
+
+ ACTION_ERASE -> {
+ components.core.sessionManager.removeAndCloseAllPrivateSessions()
+
+ if (!VisibilityLifecycleCallback.finishAndRemoveTaskIfInBackground(this)) {
+ startActivity(
+ Intent(this, HomeActivity::class.java).apply {
+ this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ )
+ }
+ }
+
+ else -> throw IllegalStateException("Unknown intent: $intent")
+ }
+
+ return Service.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))
+ .setContentText(getString(R.string.notification_pbm_delete_text))
+ .setContentIntent(createNotificationIntent())
+ .setVisibility(NotificationCompat.VISIBILITY_SECRET)
+ .setShowWhen(false)
+ .setLocalOnly(true)
+ .setColor(ContextCompat.getColor(this, R.color.pbm_notification_color))
+ .addAction(
+ NotificationCompat.Action(
+ 0,
+ getString(R.string.notification_pbm_action_open),
+ createOpenActionIntent()
+ )
+ )
+ .addAction(
+ NotificationCompat.Action(
+ 0,
+ getString(R.string.notification_pbm_action_delete_and_open),
+ createOpenAndEraseActionIntent()
+ )
+ )
+ .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 createOpenActionIntent(): PendingIntent {
+ val intent = Intent(this, HomeActivity::class.java)
+
+ return PendingIntent.getActivity(this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)
+ }
+
+ private fun createOpenAndEraseActionIntent(): PendingIntent {
+ val intent = Intent(this, HomeActivity::class.java)
+
+ intent.putExtra(HomeActivity.EXTRA_DELETE_PRIVATE_TABS, true)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+
+ return PendingIntent.getActivity(this, 2, intent, PendingIntent.FLAG_CANCEL_CURRENT)
+ }
+
+ 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 ACTION_START = "start"
+ private const val ACTION_ERASE = "erase"
+
+ internal fun start(context: Context) {
+ val intent = Intent(context, SessionNotificationService::class.java)
+ intent.action = ACTION_START
+
+ // 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)
+ })
+ }
+
+ 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)
+ })
+ }
+ }
+}
diff --git a/app/src/main/java/org/mozilla/fenix/session/VisibilityLifecycleCallback.kt b/app/src/main/java/org/mozilla/fenix/session/VisibilityLifecycleCallback.kt
new file mode 100644
index 0000000000..20c6a365f5
--- /dev/null
+++ b/app/src/main/java/org/mozilla/fenix/session/VisibilityLifecycleCallback.kt
@@ -0,0 +1,68 @@
+/* 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.Activity
+import android.app.ActivityManager
+import android.app.Application
+import android.content.Context
+import android.os.Bundle
+import org.mozilla.fenix.FenixApplication
+
+/**
+ * This ActivityLifecycleCallbacks implementations tracks if there is at least one activity in the
+ * STARTED state (meaning some part of our application is visible).
+ * Based on this information the current task can be removed if the app is not visible.
+ */
+@SuppressWarnings("EmptyFunctionBlock")
+class VisibilityLifecycleCallback(private val activityManager: ActivityManager?) :
+ Application.ActivityLifecycleCallbacks {
+
+ /**
+ * Activities are not stopped/started in an ordered way. So we are using
+ */
+ private var activitiesInStartedState: Int = 0
+
+ private fun finishAndRemoveTaskIfInBackground(): Boolean {
+ if (activitiesInStartedState == 0) {
+ activityManager?.let {
+ for (task in it.appTasks) {
+ task.finishAndRemoveTask()
+ }
+ return true
+ }
+ }
+ return false
+ }
+
+ override fun onActivityStarted(activity: Activity?) {
+ activitiesInStartedState++
+ }
+
+ override fun onActivityStopped(activity: Activity?) {
+ activitiesInStartedState--
+ }
+
+ override fun onActivityResumed(activity: Activity?) {}
+
+ override fun onActivityPaused(activity: Activity?) {}
+
+ override fun onActivityCreated(activity: Activity?, bundle: Bundle?) {}
+
+ override fun onActivitySaveInstanceState(activity: Activity?, bundle: Bundle?) {}
+
+ override fun onActivityDestroyed(activity: Activity?) {}
+
+ companion object {
+ /**
+ * If all activities of this app are in the background then finish and remove all tasks. After
+ * that the app won't show up in "recent apps" anymore.
+ */
+ internal fun finishAndRemoveTaskIfInBackground(context: Context): Boolean {
+ return (context.applicationContext as FenixApplication)
+ .visibilityLifecycleCallback?.finishAndRemoveTaskIfInBackground() ?: false
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_pbm_notification.xml b/app/src/main/res/drawable/ic_pbm_notification.xml
new file mode 100644
index 0000000000..28f6aa7fa9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pbm_notification.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 4b91d90196..88b7e9fb51 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -214,4 +214,7 @@
#737373
+
+
+ #592ACB
diff --git a/app/src/main/res/values/static_strings.xml b/app/src/main/res/values/static_strings.xml
index dc1b21c4b7..ed3f7871f9 100644
--- a/app/src/main/res/values/static_strings.xml
+++ b/app/src/main/res/values/static_strings.xml
@@ -5,6 +5,7 @@
Firefox Preview
+ Firefox Preview (Private)
LeakCanary
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 69ddcc6ca1..52f2f736d3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -568,6 +568,14 @@
Got it
+
+ Private browsing session
+
+ Delete private tabs
+
+ Open
+
+ Delete and Open
Collection deleted