diff --git a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt index 1527c93c..2162cd1d 100644 --- a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt +++ b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt @@ -20,8 +20,10 @@ package eu.weblibre.gecko import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log +import eu.weblibre.flutter_mozilla_components.HomePressDispatcher import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache @@ -29,9 +31,12 @@ import io.flutter.embedding.engine.FlutterJNI import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterFragmentActivity() { +class MainActivity : FlutterFragmentActivity() { companion object { private const val TAG = "MainActivity" + + /** Marks the intent [checkAndExitPiP] sends to itself. */ + private const val EXTRA_EXIT_PIP = "eu.weblibre.gecko.EXIT_PIP" } private val TRIM_MEMORY_CHANNEL = "eu.weblibre.flutter_mozilla_components/trim_memory" @@ -48,6 +53,45 @@ class MainActivity: FlutterFragmentActivity() { "cachedEngine=${engineTag(FlutterEngineCache.getInstance().get(ENGINE_ID))}") super.onCreate(null) + + // Restored straight into a pinned task (e.g. the process was killed while in PiP). + checkAndExitPiP() + } + + override fun onNewIntent(intent: Intent) { + if (intent.hasExtra(EXTRA_EXIT_PIP)) { + // Our own intent from checkAndExitPiP, delivered back here because + // FLAG_ACTIVITY_REORDER_TO_FRONT re-delivers to a singleTask activity. It carries + // nothing for Flutter to handle, and acting on it again would recurse. + return + } + + super.onNewIntent(intent) + + checkAndExitPiP() + } + + /** + * A picture-in-picture window cannot show a newly opened page, so an intent arriving + * while we are in PiP has to pull the task back to the foreground first. + */ + private fun checkAndExitPiP() { + if (isInPictureInPictureMode) { + moveTaskToBack(false) + startActivity( + Intent(this, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + .putExtra(EXTRA_EXIT_PIP, true) + ) + } + } + + override fun onUserLeaveHint() { + if (HomePressDispatcher.onUserLeaveHint(this)) { + return + } + + super.onUserLeaveHint() } /** diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index bf8faaed..998a3e22 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -25,19 +25,25 @@ import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature +import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import eu.weblibre.flutter_mozilla_components.ext.toPigeonDownloadState import eu.weblibre.flutter_mozilla_components.feature.BrowserHandlingScrollFeature +import eu.weblibre.flutter_mozilla_components.feature.GestureAwareSwipeRefreshFeature import eu.weblibre.flutter_mozilla_components.feature.KeyboardVisibilityFeature import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration -import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity import eu.weblibre.flutter_mozilla_components.services.DownloadService import io.flutter.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.mapNotNull import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab +import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab +import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.WebExtensionState import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.concept.engine.EngineView @@ -55,12 +61,12 @@ import mozilla.components.feature.prompts.file.AndroidPhotoPicker import mozilla.components.feature.session.FullScreenFeature import mozilla.components.feature.session.PictureInPictureFeature import mozilla.components.feature.session.SessionFeature -import eu.weblibre.flutter_mozilla_components.feature.GestureAwareSwipeRefreshFeature import mozilla.components.feature.sitepermissions.SitePermissionsFeature import mozilla.components.feature.sitepermissions.SitePermissionsRules import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction import mozilla.components.feature.tabs.WindowFeature import mozilla.components.feature.webauthn.WebAuthnFeature +import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.base.feature.ActivityResultHandler import mozilla.components.support.base.feature.PermissionsFeature import mozilla.components.support.base.feature.UserInteractionHandler @@ -471,6 +477,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = binding.root, ) + components.core.store.flowScoped(viewLifecycleOwner, Dispatchers.Main) { flow -> + flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(sessionId) } + .distinctUntilChangedBy { tab -> tab.content.pictureInPictureEnabled } + .collect { tab -> pipModeChanged(tab) } + } + mediaSessionFullscreenFeature.set( feature = MediaSessionFullscreenFeature( requireActivity(), @@ -644,8 +656,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit override fun onPictureInPictureModeChanged(enabled: Boolean) { pictureInPictureFeature?.onPictureInPictureModeChanged(enabled) - if (lifecycle.currentState == androidx.lifecycle.Lifecycle.State.CREATED) { + } + + private fun pipModeChanged(session: SessionState) { + if (!session.content.pictureInPictureEnabled && session.content.fullScreen && isAdded) { onBackPressed() + fullScreenChanged(false) } } @@ -691,6 +707,17 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit keyboardVisibilityFeature?.checkKeyboardState() } + @CallSuper + override fun onStop() { + super.onStop() + + components.core.store.state.findTabOrCustomTabOrSelectedTab(sessionId)?.let { session -> + if (!session.content.pictureInPictureEnabled && fullScreenFeature.onBackPressed()) { + fullScreenChanged(false) + } + } + } + override fun onDestroyView() { super.onDestroyView() @@ -702,6 +729,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit browserHandlingScrollFeature?.stop() browserHandlingScrollFeature = null + // Holds the Activity, so it must not outlive the view it was created with. + pictureInPictureFeature = null + GlobalComponents.onPullToRefreshEnabledChanged = null GlobalComponents.onScreenshotProtectionEnabledChanged = null val engineView = fragmentEngineView diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/HomePressDispatcher.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/HomePressDispatcher.kt new file mode 100644 index 00000000..6bf0df20 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/HomePressDispatcher.kt @@ -0,0 +1,47 @@ +/* + * 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 https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import mozilla.components.support.base.feature.UserInteractionHandler + +/** + * Dispatches "the user is leaving the app" to the browser fragments so they can enter + * picture-in-picture. + * + * Lives in this package because Mozilla Components is an `implementation` dependency, so + * [UserInteractionHandler] is not on the app module's compile classpath. + */ +object HomePressDispatcher { + /** + * Returns true if a fragment handled the home press (i.e. entered picture-in-picture), + * in which case the caller must not run the default `onUserLeaveHint` behaviour. + */ + fun onUserLeaveHint(activity: FragmentActivity): Boolean { + // Runtime permission prompts trigger onUserLeaveHint too. That is not the user + // leaving, so it must not put a playing fullscreen video into picture-in-picture. + if (GlobalComponents.components?.notificationsDelegate?.isRequestingPermission == true) { + return false + } + + return activity.supportFragmentManager.fragments.any(::dispatch) + } + + private fun dispatch(fragment: Fragment): Boolean { + // A hidden or paused fragment must not be able to grab picture-in-picture. + if (!fragment.isResumed) { + return false + } + + if (fragment is UserInteractionHandler && fragment.onHomePressed()) { + return true + } + + return fragment.childFragmentManager.fragments.any(::dispatch) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt index 05a68908..342c38ad 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt @@ -16,6 +16,7 @@ import androidx.core.view.WindowCompat import eu.weblibre.flutter_mozilla_components.ColorSchemePreference import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.HomePressDispatcher import eu.weblibre.flutter_mozilla_components.PwaConstants import eu.weblibre.flutter_mozilla_components.PwaSessionCreator import eu.weblibre.flutter_mozilla_components.R @@ -122,6 +123,14 @@ open class ExternalAppBrowserActivity : AppCompatActivity() { showFragment(sessionId) } + override fun onUserLeaveHint() { + if (HomePressDispatcher.onUserLeaveHint(this)) { + return + } + + super.onUserLeaveHint() + } + private fun waitForComponents(sessionId: String) { coroutineScope.launch { var elapsedMs = 0L @@ -271,12 +280,4 @@ open class ExternalAppBrowserActivity : AppCompatActivity() { } } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) - val fragment = supportFragmentManager.findFragmentById(R.id.container) - if (fragment is ExternalAppBrowserFragment) { - fragment.onPictureInPictureModeChanged(isInPictureInPictureMode) - } - } - }