Initial structure for R8Assistant

Simple initial structure with simple instrumentation of Class.newInstance and getDeclaredmethod

Utilize the existing converter for converting the instrumentation callback classes.

Converts to IR and rewrites when reflective calls are found.

Bug: b/393265921
Change-Id: I5c4fb64f71c3e7642d3289b5f70236bbcce49de5
diff --git a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
index 51bacdc..0b76de3 100644
--- a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
+++ b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
@@ -920,5 +920,9 @@
     ClassConflictResolver getClassConflictResolver() {
       return classConflictResolver;
     }
+
+    boolean hasNativeMultidex() {
+      return isMinApiLevelSet() && getMinApiLevel() >= AndroidApiLevel.L.getLevel();
+    }
   }
 }
diff --git a/src/main/java/com/android/tools/r8/R8Assistant.java b/src/main/java/com/android/tools/r8/R8Assistant.java
new file mode 100644
index 0000000..2e26ebe
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/R8Assistant.java
@@ -0,0 +1,80 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+import static com.android.tools.r8.utils.ExceptionUtils.unwrapExecutionException;
+
+import com.android.tools.r8.assistant.ReflectiveInstrumentation;
+import com.android.tools.r8.dex.ApplicationReader;
+import com.android.tools.r8.dex.ApplicationWriter;
+import com.android.tools.r8.graph.AppInfo;
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.LazyLoadedDexApplication;
+import com.android.tools.r8.ir.conversion.PrimaryD8L8IRConverter;
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+import com.android.tools.r8.synthesis.SyntheticFinalization;
+import com.android.tools.r8.synthesis.SyntheticItems.GlobalSyntheticsStrategy;
+import com.android.tools.r8.utils.ExceptionUtils;
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.ThreadUtils;
+import com.android.tools.r8.utils.Timing;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * This is an experimental API for injecting reflective identification callbacks into dex code. This
+ * API is subject to change.
+ */
+@KeepForApi
+public class R8Assistant {
+
+  public static void run(R8AssistantCommand command) throws CompilationFailedException {
+    InternalOptions options = command.getInternalOptions();
+    ExceptionUtils.withCompilationHandler(
+        options.reporter,
+        () -> runInternal(command, options, ThreadUtils.getExecutorService(options)));
+  }
+
+  public static void run(R8AssistantCommand command, ExecutorService executor)
+      throws CompilationFailedException {
+    InternalOptions options = command.getInternalOptions();
+    ExceptionUtils.withD8CompilationHandler(
+        command.getReporter(),
+        () -> {
+          runInternal(command, options, executor);
+        });
+  }
+
+  static void runInternal(
+      R8AssistantCommand command, InternalOptions options, ExecutorService executorService)
+      throws IOException {
+    Timing timing = new Timing("R8 Assistant " + Version.LABEL);
+    try {
+      ApplicationReader applicationReader =
+          new ApplicationReader(command.getInputApp(), options, timing);
+      LazyLoadedDexApplication app = applicationReader.read(executorService);
+      assert !command.getInputApp().hasMainDexList();
+      AppInfo appInfo =
+          AppInfo.createInitialAppInfo(app, GlobalSyntheticsStrategy.forSingleOutputMode());
+      AppView<AppInfo> appView = AppView.createForD8(appInfo);
+      PrimaryD8L8IRConverter converter = new PrimaryD8L8IRConverter(appView, timing);
+      ReflectiveInstrumentation reflectiveInstrumentation =
+          new ReflectiveInstrumentation(appView, converter, timing);
+      reflectiveInstrumentation.instrumentClasses();
+      // Convert cf classes
+      converter.convert(appView, executorService);
+      SyntheticFinalization.finalize(appView, timing, executorService);
+      ApplicationWriter writer = ApplicationWriter.create(appView, options.getMarker());
+      writer.write(executorService);
+    } catch (ExecutionException e) {
+      throw unwrapExecutionException(e);
+    } finally {
+      options.signalFinishedToConsumers();
+      if (options.isPrintTimesReportingEnabled()) {
+        timing.report();
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/R8AssistantCommand.java b/src/main/java/com/android/tools/r8/R8AssistantCommand.java
new file mode 100644
index 0000000..f5dba70
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/R8AssistantCommand.java
@@ -0,0 +1,144 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+import com.android.tools.r8.assistant.ClassInjectionHelper;
+import com.android.tools.r8.assistant.runtime.ReflectiveOperationReceiver;
+import com.android.tools.r8.assistant.runtime.ReflectiveOracle;
+import com.android.tools.r8.assistant.runtime.ReflectiveOracle.ReflectiveOperationLogger;
+import com.android.tools.r8.assistant.runtime.ReflectiveOracle.Stack;
+import com.android.tools.r8.dex.Marker;
+import com.android.tools.r8.dex.Marker.Backend;
+import com.android.tools.r8.dex.Marker.Tool;
+import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+import com.android.tools.r8.origin.SynthesizedOrigin;
+import com.android.tools.r8.utils.AndroidApiLevel;
+import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.DumpInputFlags;
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.InternalOptions.DesugarState;
+import com.android.tools.r8.utils.Reporter;
+import com.android.tools.r8.utils.ThreadUtils;
+import java.util.Collections;
+
+/**
+ * This is an experimental API for injecting reflective identification callbacks into dex code. This
+ * API is subject to change.
+ */
+@KeepForApi
+public class R8AssistantCommand extends BaseCompilerCommand {
+
+  public R8AssistantCommand(
+      AndroidApp app,
+      CompilationMode mode,
+      ProgramConsumer programConsumer,
+      int minApiLevel,
+      Reporter reporter) {
+    super(
+        app,
+        mode,
+        programConsumer,
+        StringConsumer.emptyConsumer(),
+        minApiLevel,
+        reporter,
+        DesugarState.ON,
+        false,
+        false,
+        (a, b) -> true,
+        Collections.emptyList(),
+        Collections.emptyList(),
+        ThreadUtils.NOT_SPECIFIED,
+        DumpInputFlags.noDump(),
+        null,
+        null,
+        false,
+        Collections.emptyList(),
+        Collections.emptyList(),
+        null,
+        null);
+  }
+
+  public static Builder builder(DiagnosticsHandler reporter) {
+    return new Builder(reporter);
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  @Override
+  InternalOptions getInternalOptions() {
+    DexItemFactory factory = new DexItemFactory();
+    InternalOptions options = new InternalOptions(factory, getReporter());
+    options.setMinApiLevel(AndroidApiLevel.getAndroidApiLevel(getMinApiLevel()));
+    options.passthroughDexCode = true;
+    options.tool = Tool.R8Assistant;
+    Marker marker = new Marker(Tool.R8Assistant);
+    marker.setBackend(Backend.DEX);
+    marker.setMinApi(getMinApiLevel());
+    options.setMarker(marker);
+    options.programConsumer = getProgramConsumer();
+    return options;
+  }
+
+  /**
+   * This is an experimental API for injecting reflective identification callbacks into dex code.
+   * This API is subject to change.
+   */
+  @KeepForApi
+  public static class Builder extends BaseCompilerCommand.Builder<R8AssistantCommand, Builder> {
+
+    private Builder() {
+      this(new DiagnosticsHandler() {});
+    }
+
+    @Override
+    void validate() {
+      if (!(getProgramConsumer() instanceof DexIndexedConsumer)) {
+        getReporter().error("R8 assistant does not support CF output.");
+      }
+      if (!hasNativeMultidex()) {
+        getReporter().error("R8 assistant requires min api >= 21");
+      }
+    }
+
+    @Override
+    CompilationMode defaultCompilationMode() {
+      return CompilationMode.RELEASE;
+    }
+
+    private Builder(DiagnosticsHandler diagnosticsHandler) {
+      super(diagnosticsHandler);
+    }
+
+    @Override
+    Builder self() {
+      return this;
+    }
+
+    @Override
+    R8AssistantCommand makeCommand() {
+      ClassInjectionHelper injectionHelper = new ClassInjectionHelper(getReporter());
+      String reason = "Reflective instrumentation";
+      addClassProgramData(
+          injectionHelper.getClassBytes(ReflectiveOracle.class),
+          new SynthesizedOrigin(reason, ReflectiveOracle.class));
+      addClassProgramData(
+          injectionHelper.getClassBytes(Stack.class), new SynthesizedOrigin(reason, Stack.class));
+      addClassProgramData(
+          injectionHelper.getClassBytes(ReflectiveOperationReceiver.class),
+          new SynthesizedOrigin(reason, ReflectiveOperationReceiver.class));
+      addClassProgramData(
+          injectionHelper.getClassBytes(ReflectiveOperationLogger.class),
+          new SynthesizedOrigin(reason, ReflectiveOperationLogger.class));
+      return new R8AssistantCommand(
+          getAppBuilder().build(),
+          getMode(),
+          getProgramConsumer(),
+          getMinApiLevel(),
+          getReporter());
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/R8Command.java b/src/main/java/com/android/tools/r8/R8Command.java
index 42672c6..77778d0 100644
--- a/src/main/java/com/android/tools/r8/R8Command.java
+++ b/src/main/java/com/android/tools/r8/R8Command.java
@@ -782,10 +782,6 @@
       return !mainDexRules.isEmpty();
     }
 
-    private boolean hasNativeMultidex() {
-      return isMinApiLevelSet() && getMinApiLevel() >= AndroidApiLevel.L.getLevel();
-    }
-
     private static void verifyResourceSplitOrProgramSplit(FeatureSplit featureSplit) {
       assert featureSplit.getProgramConsumer() instanceof DexIndexedConsumer
           || featureSplit.getAndroidResourceProvider() != null;
diff --git a/src/main/java/com/android/tools/r8/assistant/ClassInjectionHelper.java b/src/main/java/com/android/tools/r8/assistant/ClassInjectionHelper.java
new file mode 100644
index 0000000..0698cc0
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/assistant/ClassInjectionHelper.java
@@ -0,0 +1,37 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.assistant;
+
+import com.android.tools.r8.R8Assistant;
+import com.android.tools.r8.utils.DescriptorUtils;
+import com.android.tools.r8.utils.ExceptionDiagnostic;
+import com.android.tools.r8.utils.Reporter;
+import com.android.tools.r8.utils.StringDiagnostic;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class ClassInjectionHelper {
+
+  private final Reporter reporter;
+
+  public ClassInjectionHelper(Reporter reporter) {
+    this.reporter = reporter;
+  }
+
+  public byte[] getClassBytes(Class<?> clazz) {
+    String classFilePath = DescriptorUtils.getPathFromJavaType(clazz);
+    try (InputStream inputStream =
+        R8Assistant.class.getClassLoader().getResourceAsStream(classFilePath)) {
+      if (inputStream == null) {
+        reporter.error(new StringDiagnostic("Could not open class file: " + classFilePath));
+        return null;
+      }
+      return ByteStreams.toByteArray(inputStream);
+    } catch (IOException e) {
+      reporter.error(new ExceptionDiagnostic(e));
+      return null;
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/assistant/ReflectiveInstrumentation.java b/src/main/java/com/android/tools/r8/assistant/ReflectiveInstrumentation.java
new file mode 100644
index 0000000..1df0015
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/assistant/ReflectiveInstrumentation.java
@@ -0,0 +1,132 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.assistant;
+
+import com.android.tools.r8.assistant.runtime.ReflectiveOracle;
+import com.android.tools.r8.graph.AppInfo;
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.DexProgramClass;
+import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.ir.code.BasicBlockInstructionListIterator;
+import com.android.tools.r8.ir.code.BasicBlockIterator;
+import com.android.tools.r8.ir.code.IRCode;
+import com.android.tools.r8.ir.code.Instruction;
+import com.android.tools.r8.ir.code.InvokeStatic;
+import com.android.tools.r8.ir.code.InvokeVirtual;
+import com.android.tools.r8.ir.conversion.PrimaryD8L8IRConverter;
+import com.android.tools.r8.ir.optimize.info.OptimizationFeedback;
+import com.android.tools.r8.utils.DescriptorUtils;
+import com.android.tools.r8.utils.Timing;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+public class ReflectiveInstrumentation {
+
+  private final AppView<AppInfo> appView;
+  private final PrimaryD8L8IRConverter converter;
+  private final DexItemFactory dexItemFactory;
+  private final Timing timing;
+  private final DexType reflectiveOracleType;
+
+  public ReflectiveInstrumentation(
+      AppView<AppInfo> appView, PrimaryD8L8IRConverter converter, Timing timing) {
+    this.appView = appView;
+    this.dexItemFactory = appView.dexItemFactory();
+    this.converter = converter;
+    this.timing = timing;
+    this.reflectiveOracleType =
+        dexItemFactory.createType(DescriptorUtils.javaClassToDescriptor(ReflectiveOracle.class));
+  }
+
+  // TODO(b/394013779): Do this in parallel.
+  public void instrumentClasses() {
+    ImmutableMap<DexMethod, DexMethod> instrumentedMethodsAndTargets =
+        getInstrumentedMethodsAndTargets();
+    for (DexProgramClass clazz : appView.appInfo().classes()) {
+      clazz.forEachProgramMethodMatching(
+          method -> method.hasCode() && method.getCode().isDexCode(),
+          method -> {
+            boolean changed = false;
+            // TODO(b/394016252): Consider using UseRegistry for determining that we need IR.
+            IRCode irCode = method.buildIR(appView);
+            BasicBlockIterator blockIterator = irCode.listIterator();
+            while (blockIterator.hasNext()) {
+              BasicBlockInstructionListIterator instructionIterator =
+                  blockIterator.next().listIterator();
+              while (instructionIterator.hasNext()) {
+                Instruction instruction = instructionIterator.next();
+                if (!instruction.isInvokeVirtual()) {
+                  continue;
+                }
+                InvokeVirtual invokeVirtual = instruction.asInvokeVirtual();
+                DexMethod invokedMethod = invokeVirtual.getInvokedMethod();
+                DexMethod toInstrumentCallTo = instrumentedMethodsAndTargets.get(invokedMethod);
+                if (toInstrumentCallTo != null) {
+                  insertCallToMethod(
+                      toInstrumentCallTo,
+                      irCode,
+                      blockIterator,
+                      instructionIterator,
+                      invokeVirtual);
+                  changed = true;
+                }
+              }
+            }
+            if (changed) {
+              converter.removeDeadCodeAndFinalizeIR(
+                  irCode, OptimizationFeedback.getIgnoreFeedback(), timing);
+            }
+          });
+    }
+  }
+
+  private ImmutableMap<DexMethod, DexMethod> getInstrumentedMethodsAndTargets() {
+    return ImmutableMap.of(
+        dexItemFactory.classMethods.newInstance,
+        getMethodReferenceWithClassParameter("onClassNewInstance"),
+        dexItemFactory.classMethods.getDeclaredMethod,
+        getMethodReferenceWithClassMethodNameAndParameters("onClassGetDeclaredMethod"));
+  }
+
+  private DexMethod getMethodReferenceWithClassParameter(String name) {
+    return dexItemFactory.createMethod(
+        reflectiveOracleType,
+        dexItemFactory.createProto(dexItemFactory.voidType, dexItemFactory.classType),
+        name);
+  }
+
+  private DexMethod getMethodReferenceWithClassMethodNameAndParameters(String name) {
+    return dexItemFactory.createMethod(
+        reflectiveOracleType,
+        dexItemFactory.createProto(
+            dexItemFactory.voidType,
+            dexItemFactory.classType,
+            dexItemFactory.stringType,
+            dexItemFactory.classArrayType),
+        name);
+  }
+
+  private void insertCallToMethod(
+      DexMethod method,
+      IRCode code,
+      BasicBlockIterator blockIterator,
+      BasicBlockInstructionListIterator instructionIterator,
+      InvokeVirtual invoke) {
+    InvokeStatic invokeStatic =
+        InvokeStatic.builder()
+            .setMethod(method)
+            .setArguments(invoke.inValues())
+            // Same position so that the stack trace has the correct line number.
+            .setPosition(invoke)
+            .build();
+    instructionIterator.previous();
+    instructionIterator.addPossiblyThrowingInstructionsToPossiblyThrowingBlock(
+        code, blockIterator, ImmutableList.of(invokeStatic), appView.options());
+    if (instructionIterator.hasNext()) {
+      instructionIterator.next();
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOperationReceiver.java b/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOperationReceiver.java
new file mode 100644
index 0000000..62def20
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOperationReceiver.java
@@ -0,0 +1,19 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.assistant.runtime;
+
+import com.android.tools.r8.assistant.runtime.ReflectiveOracle.Stack;
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+
+@KeepForApi
+public interface ReflectiveOperationReceiver {
+
+  default boolean requiresStackInformation() {
+    return false;
+  }
+
+  void onClassNewInstance(Stack stack, Class<?> clazz);
+
+  void onClassGetDeclaredMethod(Stack stack, Class<?> clazz, String method, Class<?>... parameters);
+}
diff --git a/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOracle.java b/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOracle.java
new file mode 100644
index 0000000..9f38551
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/assistant/runtime/ReflectiveOracle.java
@@ -0,0 +1,85 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.assistant.runtime;
+
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+import java.util.Arrays;
+
+@KeepForApi
+public class ReflectiveOracle {
+
+  private static Object instanceLock = new Object();
+  private static volatile ReflectiveOperationReceiver INSTANCE;
+
+  private static ReflectiveOperationReceiver getInstance() {
+    if (INSTANCE == null) {
+      // TODO(b/393249304): Support injecting alternative receiver.
+      synchronized (instanceLock) {
+        if (INSTANCE == null) {
+          INSTANCE = new ReflectiveOperationLogger();
+        }
+      }
+    }
+    return INSTANCE;
+  }
+
+  public static class Stack {
+
+    private final StackTraceElement[] stackTraceElements;
+
+    private Stack(StackTraceElement[] stackTraceElements) {
+      this.stackTraceElements = stackTraceElements;
+    }
+
+    static Stack createStack() {
+      assert INSTANCE != null;
+      if (INSTANCE.requiresStackInformation()) {
+        StackTraceElement[] stackTrace = new RuntimeException().getStackTrace();
+        return new Stack(Arrays.copyOfRange(stackTrace, 2, stackTrace.length));
+      }
+      return new Stack(null);
+    }
+
+    public StackTraceElement[] getStackTraceElements() {
+      return stackTraceElements;
+    }
+
+    public String toStringStackTrace() {
+      if (stackTraceElements == null) {
+        return "Stack extraction not enabled.";
+      }
+      StringBuilder sb = new StringBuilder();
+      for (StackTraceElement element : stackTraceElements) {
+        sb.append(" at ").append(element).append("\n");
+      }
+      return sb.toString();
+    }
+  }
+
+  public static void onClassNewInstance(Class<?> clazz) {
+    getInstance().onClassNewInstance(Stack.createStack(), clazz);
+  }
+
+  public static void onClassGetDeclaredMethod(Class<?> clazz, String name, Class<?>... parameters) {
+    getInstance().onClassGetDeclaredMethod(Stack.createStack(), clazz, name, parameters);
+  }
+
+  public static class ReflectiveOperationLogger implements ReflectiveOperationReceiver {
+    @Override
+    public void onClassNewInstance(Stack stack, Class<?> clazz) {
+      System.out.println("Reflectively created new instance of " + clazz.getName());
+    }
+
+    @Override
+    public void onClassGetDeclaredMethod(
+        Stack stack, Class<?> clazz, String method, Class<?>... parameters) {
+      System.out.println("Reflectively got declared method " + method + " on " + clazz.getName());
+    }
+
+    @Override
+    public boolean requiresStackInformation() {
+      return true;
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/dex/Marker.java b/src/main/java/com/android/tools/r8/dex/Marker.java
index f7414d9..1b03ca6 100644
--- a/src/main/java/com/android/tools/r8/dex/Marker.java
+++ b/src/main/java/com/android/tools/r8/dex/Marker.java
@@ -36,7 +36,8 @@
     R8,
     R8Partial,
     Relocator,
-    TraceReferences;
+    TraceReferences,
+    R8Assistant;
 
     public static Tool[] valuesR8andD8() {
       return new Tool[] {Tool.D8, Tool.R8};
@@ -55,6 +56,7 @@
   private static final String R8_PARTIAL_PREFIX = PREFIX + Tool.R8Partial + "{";
   private static final String L8_PREFIX = PREFIX + Tool.L8 + "{";
   private static final String RELOCATOR_PREFIX = PREFIX + Tool.Relocator + "{";
+  private static final String R8_ASSISTANT_PREFIX = PREFIX + Tool.R8Assistant + "{";
 
   private final JsonObject jsonObject;
   private final Tool tool;
@@ -183,6 +185,7 @@
       case D8:
       case L8:
       case R8:
+      case R8Assistant:
         // Before adding backend we would always compile to dex if min-api was specified.
         // This is not fully true for D8 which had a window from aug to oct 2020 where the min-api
         // was added for CF builds too. However, that was (and still is) only used internally and
@@ -298,6 +301,9 @@
       if (str.startsWith(RELOCATOR_PREFIX)) {
         return internalParse(Tool.Relocator, str.substring(RELOCATOR_PREFIX.length() - 1));
       }
+      if (str.startsWith(R8_ASSISTANT_PREFIX)) {
+        return internalParse(Tool.R8Assistant, str.substring(R8_ASSISTANT_PREFIX.length() - 1));
+      }
     }
     return null;
   }
diff --git a/src/test/java/com/android/tools/r8/assistant/R8AssistentReflectiveInstrumentationTest.java b/src/test/java/com/android/tools/r8/assistant/R8AssistentReflectiveInstrumentationTest.java
new file mode 100644
index 0000000..be0c3e2
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/assistant/R8AssistentReflectiveInstrumentationTest.java
@@ -0,0 +1,110 @@
+// Copyright (c) 2025, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.assistant;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.android.tools.r8.OutputMode;
+import com.android.tools.r8.R8Assistant;
+import com.android.tools.r8.R8AssistantCommand;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.ToolHelper;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.InstructionSubject;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class R8AssistentReflectiveInstrumentationTest extends TestBase {
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withNativeMultidexDexRuntimes().withMaximumApiLevel().build();
+  }
+
+  @Test
+  public void testInstrumentation() throws Exception {
+    Path d8Compilation =
+        testForD8(parameters.getBackend())
+            .addInnerClasses(getClass())
+            .setMinApi(parameters)
+            .compile()
+            .inspect(codeInspector -> inspectStaticCallsInReflectOn(0, codeInspector))
+            .writeToZip();
+    R8AssistantCommand.Builder builder = R8AssistantCommand.builder();
+    Path outputPath = temp.newFile("instrumented.jar").toPath();
+    // TODO(b/393265921): Add testForR8Assistant and avoid building up the command here
+    R8AssistantCommand command =
+        builder
+            .addProgramFiles(d8Compilation)
+            .setMinApiLevel(parameters.getApiLevel().getLevel())
+            .setOutput(outputPath, OutputMode.DexIndexed)
+            .build();
+    R8Assistant.run(command);
+    inspectStaticCallsInReflectOn(outputPath, 2);
+
+    String artOutput =
+        ToolHelper.runArtNoVerificationErrors(
+            outputPath.toString(),
+            TestClass.class
+                .getName()); // For now, just test that the printed logs are what we expect
+    String expectedNewInstanceString =
+        "Reflectively created new instance of " + Bar.class.getName();
+    assertThat(artOutput, containsString(expectedNewInstanceString));
+    String expectedGetDeclaredMethod =
+        "Reflectively got declared method callMe on " + Bar.class.getName();
+    assertThat(artOutput, containsString(expectedGetDeclaredMethod));
+  }
+
+  private static void inspectStaticCallsInReflectOn(Path outputPath, int count) throws IOException {
+    CodeInspector inspector = new CodeInspector(outputPath);
+    inspectStaticCallsInReflectOn(count, inspector);
+  }
+
+  private static void inspectStaticCallsInReflectOn(int count, CodeInspector inspector) {
+    ClassSubject testClass = inspector.clazz(TestClass.class);
+    assertThat(testClass, isPresent());
+    MethodSubject reflectOn = testClass.uniqueMethodWithOriginalName("reflectOn");
+    assertThat(reflectOn, isPresent());
+    long codeCount =
+        reflectOn.streamInstructions().filter(InstructionSubject::isInvokeStatic).count();
+    assertEquals(count, codeCount);
+  }
+
+  static class TestClass {
+    public static void main(String[] args) {
+      reflectOn(System.currentTimeMillis() == 0 ? Foo.class : Bar.class);
+    }
+
+    public static void reflectOn(Class<?> clazz) {
+      try {
+        clazz.newInstance();
+        clazz.getDeclaredMethod("callMe");
+
+      } catch (InstantiationException | IllegalAccessException | NoSuchMethodException e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  public static class Foo {}
+
+  public static class Bar {
+    public void callMe() {}
+  }
+}