Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions core/core-pip/api/current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,18 @@ package androidx.core.pip {

}

package androidx.core.pip.contentpip {

public final class ContentPip {
method public static void enablePipOnAppSwitch(androidx.activity.ComponentActivity, androidx.core.pip.contentpip.ContentPipCallback callback);
}

public interface ContentPipCallback {
method public void onAttachContentPip(androidx.activity.ComponentActivity pipActivity);
method public void onFinishContentPip(boolean isDismissed);
method public boolean onInitContentPip();
method public boolean onPrepareContentPip();
}

}

15 changes: 15 additions & 0 deletions core/core-pip/api/restricted_current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,18 @@ package androidx.core.pip {

}

package androidx.core.pip.contentpip {

public final class ContentPip {
method public static void enablePipOnAppSwitch(androidx.activity.ComponentActivity, androidx.core.pip.contentpip.ContentPipCallback callback);
}

public interface ContentPipCallback {
method public void onAttachContentPip(androidx.activity.ComponentActivity pipActivity);
method public void onFinishContentPip(boolean isDismissed);
method public boolean onInitContentPip();
method public boolean onPrepareContentPip();
}

}

2 changes: 2 additions & 0 deletions core/core-pip/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ plugins {

dependencies {
implementation("androidx.core:core:1.18.0")
implementation("androidx.activity:activity:1.13.0")
implementation("androidx.lifecycle:lifecycle-common:2.8.4")
androidTestImplementation("androidx.activity:activity:1.13.0")
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testRunner)
Expand Down
29 changes: 29 additions & 0 deletions core/core-pip/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2025 The Android Open Source Project

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="androidx.core.pip.contentpip.ContentPipInternalActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:supportsPictureInPicture="true"
android:launchMode="standard"
android:taskAffinity="androidx.core.pip.contentpip.isolated"
android:excludeFromRecents="true"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.core.pip.contentpip

import androidx.activity.ComponentActivity

/**
* A callback interface for managing the handoff of a content between the main activity and the
* Content PiP.
*/
public interface ContentPipCallback {
/**
* Called before attempting to start the Content PiP solution.
*
* @return `true` if the application is eligible to enter PiP. If `false` is returned, the PiP
* flow is canceled.
*/
public fun onInitContentPip(): Boolean

/**
* Called on the main Activity to prepare for the handoff (e.g., detaching a Player from its
* View).
*
* @return `true` if the handoff is successful on the main Activity. If `false` is returned, the
* PiP flow is canceled.
*/
public fun onPrepareContentPip(): Boolean

/**
* Called once the proxy PiP Activity is ready. The app should attach its content (e.g., a
* Player) to this new [pipActivity].
*
* @param pipActivity The new Activity serving as the PiP container.
*/
public fun onAttachContentPip(pipActivity: ComponentActivity)

/**
* Called when the PiP task is finishing.
*
* For instance, a video app can use [isDismissed] to determine if it needs to stop playback.
* This is `true` when the user dismisses or closes the PiP, and `false` when the user expands
* the PiP to full-screen mode.
*
* @param isDismissed whether the PiP task is explicitly dismissed by the user.
*/
public fun onFinishContentPip(isDismissed: Boolean)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:JvmName("ContentPip")

package androidx.core.pip.contentpip

import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Window
import androidx.activity.ComponentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

/**
* Enables entering PiP on app switch for this Activity with Content PiP.
*
* This API hooks into standard Activity exit hints to trigger a fallback PiP pipeline when native
* [Auto-Enter](https://developer.android.com/reference/android/app/PictureInPictureParams#isAutoEnterEnabled())
* is suppressed by the OS (e.g. on Quick Switch).
*
* It also automatically monitors the Activity lifecycle and user interactions to "pull back" the
* player and terminate the fallback task when the user returns to the app.
*
* **Threading:** This callback is strictly tied to the UI toolkit and will **always** be executed
* synchronously on the main thread of your application.
*
* @param callback The callback to manage handoff between tasks.
*/
@SuppressLint("ExecutorRegistration")
public fun ComponentActivity.enablePipOnAppSwitch(callback: ContentPipCallback) {
addOnUserLeaveHintListener {
// Triggered synchronously to avoid interrupting system transitions
if (callback.onInitContentPip()) {
ContentPipManager.triggerFallback(this, callback)
}
}

// Automatically handle "Pull Back" when returning to the app via lifecycle
val handler = Handler(Looper.getMainLooper())
lifecycle.addObserver(
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Delay slightly to ensure smooth transition back from PiP/Dialog
handler.postDelayed({ ContentPipManager.requestFinish() }, 200L)
}
Lifecycle.Event.ON_DESTROY -> {
// If the activity is finishing (not just rotating), cleanup the proxy task
if (isFinishing) {
ContentPipManager.requestFinish()
}
}
else -> {}
}
}
)

// Automatically handle "Pull Back" on user interaction
val originalCallback = window.callback
window.callback =
ContentPipWindowCallback(originalCallback) { ContentPipManager.requestFinish() }
}

/** An internal [Window.Callback] wrapper that intercepts interactions to signal PiP teardown. */
private class ContentPipWindowCallback(
private val delegate: Window.Callback,
private val onInteraction: () -> Unit,
) : Window.Callback by delegate {
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
onInteraction()
return delegate.dispatchTouchEvent(event)
}

override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
onInteraction()
return delegate.dispatchKeyEvent(event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.core.pip.contentpip

import android.app.Activity
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity

/** An internal translucent Activity that serves as the PiP container in a separate task. */
internal class ContentPipInternalActivity : ComponentActivity() {
private var callback: ContentPipCallback? = null
private var hasEnteredPip = false
private var isStopped = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applyZeroTransitions()

val cb = ContentPipManager.consumeHandoff()
if (cb == null) {
finishAndRemoveTask()
return
}

callback = cb
cb.onAttachContentPip(this)

// Register the finish hook so the main activity can "pull back" the player
ContentPipManager.registerFinishProxyHook { finishAndRemoveTask() }

// Move task to back immediately so it doesn't interrupt the user's current gesture
moveTaskToBack(true)
}

override fun onStart() {
super.onStart()
if (!hasEnteredPip) {
hasEnteredPip = true
// Trigger PiP from the backgrounded state
@Suppress("DEPRECATION") enterPictureInPictureMode()
}
isStopped = false
}

override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: android.content.res.Configuration,
) {
@Suppress("DEPRECATION")
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
// Expanding, teardown the task
ContentPipManager.onTeardown(isStopped)
finishAndRemoveTask()
}
}

override fun onStop() {
super.onStop()
isStopped = true
}

private fun applyZeroTransitions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition(Activity.OVERRIDE_TRANSITION_OPEN, 0, 0)
overrideActivityTransition(Activity.OVERRIDE_TRANSITION_CLOSE, 0, 0)
} else {
@Suppress("DEPRECATION") overridePendingTransition(0, 0)
}
}
}
Loading
Loading