feat: add activity launcher for virtual activity displays

this is nice for several reasons:
- it adds a loading screen in place of what would be a broken/frozen frame buffer
- it adds a way to easily re-launch an activity that crashes
- the same applies when pressing back too many times and closing the activity
- it also allows a relatively seamless transition for apps to go from the car display to the phone and back
- in the future, I can add a callback to VirtualActivity to hide the splash screen after this activity renders its first frame to make loading even more seamless
This commit is contained in:
Benjamin Wiegand
2026-06-26 21:15:55 -07:00
parent 3a4cba374a
commit b368ca3cdd
8 changed files with 266 additions and 1 deletions
+6
View File
@@ -98,6 +98,12 @@
android:theme="@style/Theme.Geargrinder.Projection"
/>
<activity
android:name=".VirtualActivityLauncherActivity"
android:exported="true"
android:theme="@style/Theme.Geargrinder.Projection"
/>
<activity
android:name=".PermissionGrantActivity"
android:label="@string/permission_grant_activity_title"
@@ -0,0 +1,166 @@
package io.benwiegand.projection.geargrinder;
import android.content.ComponentName;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatActivity;
import io.benwiegand.projection.libprivd.ipc.IPCConstants;
public class VirtualActivityLauncherActivity extends AppCompatActivity {
private static final String TAG = VirtualActivityLauncherActivity.class.getSimpleName();
private static final String INTENT_EXTRA_ACTIVITY_COMPONENT = IPCConstants.VIRTUAL_ACTIVITY_LAUNCHER_INTENT_EXTRA_ACTIVITY;
private static final long LAUNCH_TIMEOUT = 5000;
private final Handler handler = new Handler(Looper.getMainLooper());
private ComponentName componentName;
private Object launchToken = new Object();
private boolean firstLaunch = true;
private boolean activityLost = false;
private boolean wasPaused = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_virtual_activity_launcher);
Intent intent = getIntent();
if (intent == null) {
Log.wtf(TAG, "no launch intent");
finish();
return;
}
String component = intent.getStringExtra(INTENT_EXTRA_ACTIVITY_COMPONENT);
if (component == null) {
Log.wtf(TAG, "missing activity intent extra");
finish();
return;
}
componentName = ComponentName.unflattenFromString(component);
View relaunchButton = findViewById(R.id.relaunch_button);
relaunchButton.setOnClickListener(v -> tryLaunch());
getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
Log.v(TAG, "back button consumed");
}
});
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// this is preferable since it shows a cohesive frame on the screen that accurately indicates what's currently happening.
// without this, the first frame the user sees will be blank, broken, or outdated.
View root = findViewById(R.id.root);
root.getViewTreeObserver().registerFrameCommitCallback(new Runnable() {
@Override
public void run() {
root.getViewTreeObserver().unregisterFrameCommitCallback(this);
doFirstLaunch();
}
});
handler.postDelayed(() -> {
if (!firstLaunch) return;
Log.wtf(TAG, "frame commit callback not called after " + LAUNCH_TIMEOUT + " ms! forcing first launch");
doFirstLaunch();
}, LAUNCH_TIMEOUT);
} else if (firstLaunch) {
doFirstLaunch();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.w(TAG, "onDestroy()");
}
private void updateStatus(@StringRes int text) {
TextView statusText = findViewById(R.id.launch_status_text);
View relaunchButton = findViewById(R.id.relaunch_button);
ProgressBar launchingIndicator = findViewById(R.id.launching_indicator);
statusText.setText(text);
if (text == R.string.virtual_activity_launcher_status_launching) {
relaunchButton.setVisibility(View.GONE);
launchingIndicator.setVisibility(View.VISIBLE);
} else {
relaunchButton.setVisibility(View.VISIBLE);
launchingIndicator.setVisibility(View.GONE);
}
}
private void doFirstLaunch() {
if (!firstLaunch) return;
Log.i(TAG, "first launch");
tryLaunch();
}
private void tryLaunch() {
Log.i(TAG, "launching activity: " + componentName);
launchToken = new Object();
firstLaunch = false;
activityLost = false;
wasPaused = false;
updateStatus(R.string.virtual_activity_launcher_status_launching);
try {
startActivity(new Intent().setComponent(componentName));
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
} catch (Throwable t) {
Log.e(TAG, "activity launch failed", t);
updateStatus(R.string.virtual_activity_launcher_status_failed);
return;
}
Object token = launchToken;
handler.postDelayed(() -> {
if (launchToken != token) return;
if (wasPaused) return;
Log.i(TAG, "no call to onPause after " + LAUNCH_TIMEOUT + " ms");
updateStatus(R.string.virtual_activity_launcher_status_failed);
}, LAUNCH_TIMEOUT);
}
@Override
protected void onResume() {
super.onResume();
if (firstLaunch) return;
if (activityLost) return;
Log.i(TAG, "activity lost: " + componentName);
updateStatus(R.string.virtual_activity_launcher_status_closed);
activityLost = true;
}
@Override
protected void onPause() {
super.onPause();
Log.d(TAG, "onPause()");
wasPaused = true;
}
}
@@ -168,7 +168,7 @@ public class VirtualActivity implements SurfaceHolder.Callback {
// launch
try {
int result = privd.launchActivity(app.launchComponent(), getDisplayId());
int result = privd.launchVirtualActivity(app.launchComponent(), getDisplayId());
Log.d(TAG, "launch result " + result + " for " + app.launchComponent().flattenToShortString());
if (result == -1) throw new RuntimeException("activity launch failed with code -1");
} catch (DeadObjectException e) {
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
>
<ProgressBar
android:id="@+id/launching_indicator"
android:layout_width="wrap_content"
android:layout_height="match_parent"
/>
<TextView
android:id="@+id/launch_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
style="?textAppearanceHeadline5"
tools:text="@string/virtual_activity_launcher_status_launching"
/>
</LinearLayout>
<Button
android:id="@+id/relaunch_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/relaunch_button"
/>
</LinearLayout>
+4
View File
@@ -186,4 +186,8 @@
<string name="unknown_caller">Unknown</string>
<string name="incoming_call_fallback_notice">when it is safe, answer on your phone</string>
<string name="virtual_activity_launcher_status_launching">launching</string>
<string name="virtual_activity_launcher_status_failed">app launch failed</string>
<string name="virtual_activity_launcher_status_closed">app closed</string>
</resources>
@@ -13,6 +13,8 @@ interface IPrivd {
int launchActivity(in ComponentName component, int displayId);
int launchVirtualActivity(in ComponentName component, int displayId);
int createVirtualDisplay(in String name, int width, int height, int densityDpi, in Surface surface, int flags);
void releaseVirtualDisplay(int displayId);
@@ -1,5 +1,7 @@
package io.benwiegand.projection.libprivd.ipc;
import android.content.ComponentName;
public class IPCConstants {
public static final long PING_INTERVAL = 1000;
@@ -17,4 +19,7 @@ public class IPCConstants {
public static final String INTENT_EXTRA_BINDER = "binder";
public static final String INTENT_EXTRA_TOKEN = "token";
public static final ComponentName VIRTUAL_ACTIVITY_LAUNCHER_ACTIVITY_COMPONENT = ComponentName.createRelative(APP_PKG_NAME, ".VirtualActivityLauncherActivity");
public static final String VIRTUAL_ACTIVITY_LAUNCHER_INTENT_EXTRA_ACTIVITY = "activity";
}
@@ -4,6 +4,8 @@ import static io.benwiegand.projection.geargrinder.privd.reflection.ReflectionUt
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.APP_PKG_NAME;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.BIND_TIMEOUT;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.PING_TIMEOUT;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.VIRTUAL_ACTIVITY_LAUNCHER_ACTIVITY_COMPONENT;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.VIRTUAL_ACTIVITY_LAUNCHER_INTENT_EXTRA_ACTIVITY;
import android.annotation.SuppressLint;
import android.app.ActivityOptions;
@@ -180,6 +182,44 @@ public class Privd extends IPrivd.Stub {
}
}
@Override
public int launchVirtualActivity(ComponentName component, int displayId) {
Log.v(TAG, "launching virtual activity launcher on display " + displayId + ": " + component.flattenToShortString());
if (ram != null) {
try {
Intent intent = new Intent()
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS | Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
.putExtra(VIRTUAL_ACTIVITY_LAUNCHER_INTENT_EXTRA_ACTIVITY, component.flattenToString())
.setComponent(VIRTUAL_ACTIVITY_LAUNCHER_ACTIVITY_COMPONENT);
ActivityOptions opts = ActivityOptions.makeBasic();
opts.setLaunchDisplayId(displayId);
return ram.startActivity(intent, opts.toBundle());
} catch (ReflectionException | SecurityException e) {
Log.w(TAG, "failed to launch activity via IActivityManager", e);
}
}
Log.w(TAG, "falling back to shell command for activity launch");
try {
return new ProcessBuilder("am", "start-activity",
"--activity-multiple-task",
"--es", VIRTUAL_ACTIVITY_LAUNCHER_INTENT_EXTRA_ACTIVITY, component.flattenToString(),
"--display", String.valueOf(displayId),
VIRTUAL_ACTIVITY_LAUNCHER_ACTIVITY_COMPONENT.flattenToShortString())
.start()
.waitFor();
} catch (IOException e) {
Log.e(TAG, "IOException while starting activity via shell", e);
return -1;
} catch (InterruptedException e) {
Log.e(TAG, "interrupted");
return -1;
}
}
private VirtualDisplay getVirtualDisplay(int displayId) {
VirtualDisplay virtualDisplay = virtualDisplays.get(displayId);
if (virtualDisplay == null) throw new NoSuchElementException("no registered VirtualDisplay exists with id " + displayId);