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)