From cc5408d7176b853e0b405b7c13ab931adde0ca50 Mon Sep 17 00:00:00 2001 From: Yeon Taek Jeong Date: Wed, 28 Aug 2019 12:55:24 -0700 Subject: [PATCH] For #2053: Add persistent notification to close all private browsing tabs (#4913) --- app/src/main/AndroidManifest.xml | 3 + .../org/mozilla/fenix/FenixApplication.kt | 11 ++ .../java/org/mozilla/fenix/HomeActivity.kt | 3 + .../intent/NotificationsIntentProcessor.kt | 32 ++++ .../session/NotificationSessionObserver.kt | 39 ++++ .../session/SessionNotificationService.kt | 175 ++++++++++++++++++ .../session/VisibilityLifecycleCallback.kt | 68 +++++++ .../main/res/drawable/ic_pbm_notification.xml | 13 ++ app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/static_strings.xml | 1 + app/src/main/res/values/strings.xml | 8 + 11 files changed, 356 insertions(+) create mode 100644 app/src/main/java/org/mozilla/fenix/home/intent/NotificationsIntentProcessor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/session/NotificationSessionObserver.kt create mode 100644 app/src/main/java/org/mozilla/fenix/session/SessionNotificationService.kt create mode 100644 app/src/main/java/org/mozilla/fenix/session/VisibilityLifecycleCallback.kt create mode 100644 app/src/main/res/drawable/ic_pbm_notification.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47a22ca78..1373ba7e3 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 caa455da5..5e3b99fac 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 81e4d5569..92e57e187 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 000000000..e9eb5df69 --- /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 000000000..090ecf82d --- /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 000000000..c59e22e07 --- /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 000000000..20c6a365f --- /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 000000000..28f6aa7fa --- /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 4b91d9019..88b7e9fb5 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 dc1b21c4b..ed3f7871f 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 69ddcc6ca..52f2f736d 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