From b368ca3cdd0cda75dcc98e740a0cbb9a80a7c71f Mon Sep 17 00:00:00 2001
From: Benjamin Wiegand <126627496+Benjamin-Wiegand@users.noreply.github.com>
Date: Fri, 26 Jun 2026 21:15:55 -0700
Subject: [PATCH] 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
---
app/src/main/AndroidManifest.xml | 6 +
.../VirtualActivityLauncherActivity.java | 166 ++++++++++++++++++
.../projection/ui/VirtualActivity.java | 2 +-
.../activity_virtual_activity_launcher.xml | 42 +++++
app/src/main/res/values/strings.xml | 4 +
.../projection/libprivd/IPrivd.aidl | 2 +
.../projection/libprivd/ipc/IPCConstants.java | 5 +
.../projection/geargrinder/privd/Privd.java | 40 +++++
8 files changed, 266 insertions(+), 1 deletion(-)
create mode 100644 app/src/main/java/io/benwiegand/projection/geargrinder/VirtualActivityLauncherActivity.java
create mode 100644 app/src/main/res/layout/activity_virtual_activity_launcher.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e702176..e108f30 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -98,6 +98,12 @@
android:theme="@style/Theme.Geargrinder.Projection"
/>
+
+
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;
+ }
+}
diff --git a/app/src/main/java/io/benwiegand/projection/geargrinder/projection/ui/VirtualActivity.java b/app/src/main/java/io/benwiegand/projection/geargrinder/projection/ui/VirtualActivity.java
index e505c71..7712964 100644
--- a/app/src/main/java/io/benwiegand/projection/geargrinder/projection/ui/VirtualActivity.java
+++ b/app/src/main/java/io/benwiegand/projection/geargrinder/projection/ui/VirtualActivity.java
@@ -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) {
diff --git a/app/src/main/res/layout/activity_virtual_activity_launcher.xml b/app/src/main/res/layout/activity_virtual_activity_launcher.xml
new file mode 100644
index 0000000..7a7add4
--- /dev/null
+++ b/app/src/main/res/layout/activity_virtual_activity_launcher.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 96492b4..e5be7aa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -186,4 +186,8 @@
Unknown
when it is safe, answer on your phone
+ launching
+ app launch failed
+ app closed
+
\ No newline at end of file
diff --git a/libprivd/src/main/aidl/io/benwiegand/projection/libprivd/IPrivd.aidl b/libprivd/src/main/aidl/io/benwiegand/projection/libprivd/IPrivd.aidl
index a82c964..817b322 100644
--- a/libprivd/src/main/aidl/io/benwiegand/projection/libprivd/IPrivd.aidl
+++ b/libprivd/src/main/aidl/io/benwiegand/projection/libprivd/IPrivd.aidl
@@ -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);
diff --git a/libprivd/src/main/java/io/benwiegand/projection/libprivd/ipc/IPCConstants.java b/libprivd/src/main/java/io/benwiegand/projection/libprivd/ipc/IPCConstants.java
index b61af79..fb48f39 100644
--- a/libprivd/src/main/java/io/benwiegand/projection/libprivd/ipc/IPCConstants.java
+++ b/libprivd/src/main/java/io/benwiegand/projection/libprivd/ipc/IPCConstants.java
@@ -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";
+
}
diff --git a/privd/src/main/java/io/benwiegand/projection/geargrinder/privd/Privd.java b/privd/src/main/java/io/benwiegand/projection/geargrinder/privd/Privd.java
index 68d5587..6885e29 100644
--- a/privd/src/main/java/io/benwiegand/projection/geargrinder/privd/Privd.java
+++ b/privd/src/main/java/io/benwiegand/projection/geargrinder/privd/Privd.java
@@ -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);