From 015f61969342b314e90a11e67a9314a0883f6851 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 10 Jun 2026 15:20:33 +0530 Subject: [PATCH] feat(driver): clear-state via APK reinstall when pm clear is blocked When an APK path is set, Android clear-state resets the app by uninstalling and reinstalling instead of asking the sidecar to pm clear, which hardened OEM builds (ColorOS) deny even to the adb shell user. Falls back to the sidecar clear path when no APK path is provided. --- internal/driver/sidecar/client.go | 41 ++++++++++++++- internal/driver/sidecar/client_test.go | 72 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index 82f68a9..961747b 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -4,6 +4,7 @@ package sidecar import ( "context" "fmt" + "io" "time" "google.golang.org/grpc" @@ -18,6 +19,19 @@ type Client struct { connection *grpc.ClientConn stub driverpb.DriverClient platform string + + // serial, apkPath, and output drive the Android clear-state reinstall: when + // apkPath is set, Launch resets state by uninstalling and reinstalling the + // APK instead of asking the sidecar to `pm clear`, which hardened OEM builds + // deny. Empty apkPath leaves the legacy `pm clear` path in place. + serial string + apkPath string + output io.Writer + + // reinstallApp resets an Android app to first-launch state. A seam so tests + // exercise the clear-state branch without a connected device; defaults to + // android.ReinstallApp. + reinstallApp func(ctx context.Context, serial, bundleID, apkPath string, output io.Writer) error } // SetPlatform records the target platform so capability methods (e.g. @@ -25,6 +39,15 @@ type Client struct { // Dial. func (c *Client) SetPlatform(platform string) { c.platform = platform } +// SetClearStateReinstall makes Android clear-state reset the app by reinstalling +// the APK at apkPath (uninstall+install) rather than `pm clear`. serial targets +// the device when several are connected; output receives progress lines. +func (c *Client) SetClearStateReinstall(serial, apkPath string, output io.Writer) { + c.serial = serial + c.apkPath = apkPath + c.output = output +} + // ForegroundApp reports the foreground package. Only Android is supported (via // adb); other platforms return "" so the runner skips app-scope enforcement. func (c *Client) ForegroundApp(ctx context.Context) (string, error) { @@ -51,7 +74,11 @@ func Dial(address string) (*Client, error) { if err != nil { return nil, fmt.Errorf("dial sidecar: %w", err) } - return &Client{connection: connection, stub: driverpb.NewDriverClient(connection)}, nil + return &Client{ + connection: connection, + stub: driverpb.NewDriverClient(connection), + reinstallApp: android.ReinstallApp, + }, nil } func (c *Client) Close() error { return c.connection.Close() } @@ -76,9 +103,19 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error { + sidecarClearState := clearState + if clearState && c.platform == "android" && c.apkPath != "" { + if c.output != nil { + fmt.Fprintf(c.output, "clear-state: reinstalling %s from %s\n", bundleID, c.apkPath) + } + if err := c.reinstallApp(ctx, c.serial, bundleID, c.apkPath, c.output); err != nil { + return fmt.Errorf("clear-state reinstall: %w", err) + } + sidecarClearState = false + } _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ BundleId: bundleID, - ClearState: clearState, + ClearState: sidecarClearState, Env: env, }) return err diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index 46182ac..9dd6715 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -2,6 +2,7 @@ package sidecar import ( "context" + "io" "net" "strings" "sync" @@ -300,6 +301,77 @@ func TestClient_LaunchAndTerminate(t *testing.T) { } } +func TestClient_LaunchClearStateReinstallsOnAndroid(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.SetClearStateReinstall("serial123", "/tmp/app.apk", io.Discard) + + var got struct { + serial, bundleID, apkPath string + } + called := 0 + client.reinstallApp = func(_ context.Context, serial, bundleID, apkPath string, _ io.Writer) error { + called++ + got.serial, got.bundleID, got.apkPath = serial, bundleID, apkPath + return nil + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err != nil { + t.Fatal(err) + } + if called != 1 { + t.Fatalf("reinstall called %d times, want 1", called) + } + if got.serial != "serial123" || got.bundleID != "app.folio" || got.apkPath != "/tmp/app.apk" { + t.Errorf("reinstall args wrong: %+v", got) + } + // The sidecar must not also clear: the host reinstall already reset state. + if state.fake.clearState { + t.Error("sidecar clearState should be false after host reinstall") + } + if state.fake.launchedBundleID != "app.folio" { + t.Errorf("launched bundle wrong: %q", state.fake.launchedBundleID) + } +} + +func TestClient_LaunchClearStateReinstallFailureAborts(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.SetClearStateReinstall("", "/tmp/app.apk", io.Discard) + client.reinstallApp = func(_ context.Context, _, _, _ string, _ io.Writer) error { + return context.DeadlineExceeded + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err == nil { + t.Fatal("expected launch to fail when reinstall fails") + } + if state.fake.launchedBundleID == "app.folio" { + t.Error("sidecar launch should not be called after a failed reinstall") + } +} + +func TestClient_LaunchClearStateWithoutApkPathUsesSidecarClear(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.reinstallApp = func(_ context.Context, _, _, _ string, _ io.Writer) error { + t.Fatal("reinstall should not run without an apk path") + return nil + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err != nil { + t.Fatal(err) + } + if !state.fake.clearState { + t.Error("without an apk path the sidecar clearState path must remain") + } +} + func TestClient_TapAndTapSelector(t *testing.T) { state := newHarness(t) client, _ := Dial(state.address)