Ensure that resources are kept if there are keep rules on R class fields

We would previously not keep the entries if the R class values was
embedded as static field values (no clinit)

The test is rewriting the generated R class to ensure that the field
has a static value instead of being initialized in clinit.

Note that this can only trigger if there are no references to the R class at all.

Change-Id: Id7d52c7e8af3b2463e6dc48d420b8da9fd3f2a83
diff --git a/src/main/java/com/android/tools/r8/graph/analysis/EnqueuerAnalysisCollection.java b/src/main/java/com/android/tools/r8/graph/analysis/EnqueuerAnalysisCollection.java
index a54fd90..e4f0706 100644
--- a/src/main/java/com/android/tools/r8/graph/analysis/EnqueuerAnalysisCollection.java
+++ b/src/main/java/com/android/tools/r8/graph/analysis/EnqueuerAnalysisCollection.java
@@ -22,6 +22,7 @@
 import com.android.tools.r8.shaking.Enqueuer;
 import com.android.tools.r8.shaking.Enqueuer.FieldAccessKind;
 import com.android.tools.r8.shaking.EnqueuerWorklist;
+import com.android.tools.r8.shaking.KeepReason;
 import com.android.tools.r8.utils.ArrayUtils;
 import com.android.tools.r8.utils.timing.Timing;
 import java.util.ArrayList;
@@ -54,6 +55,7 @@
   private final NewlyReachableFieldEnqueuerAnalysis[] newlyReachableFieldAnalyses;
   private final NewlyReferencedFieldEnqueuerAnalysis[] newlyReferencedFieldAnalyses;
   private final NewlyTargetedMethodEnqueuerAnalysis[] newlyTargetedMethodAnalyses;
+  private final MarkFieldAsKeptEnqueuerAnalysis[] markFieldAsKeptEnqueuerAnalyses;
 
   // Tear down events.
   private final FinishedEnqueuerAnalysis[] finishedAnalyses;
@@ -81,6 +83,7 @@
       NewlyReachableFieldEnqueuerAnalysis[] newlyReachableFieldAnalyses,
       NewlyReferencedFieldEnqueuerAnalysis[] newlyReferencedFieldAnalyses,
       NewlyTargetedMethodEnqueuerAnalysis[] newlyTargetedMethodAnalyses,
+      MarkFieldAsKeptEnqueuerAnalysis[] markFieldAsKeptEnqueuerAnalyses,
       // Tear down events.
       FinishedEnqueuerAnalysis[] finishedAnalyses,
       FixpointEnqueuerAnalysis[] fixpointAnalyses) {
@@ -105,6 +108,7 @@
     this.newlyReachableFieldAnalyses = newlyReachableFieldAnalyses;
     this.newlyReferencedFieldAnalyses = newlyReferencedFieldAnalyses;
     this.newlyTargetedMethodAnalyses = newlyTargetedMethodAnalyses;
+    this.markFieldAsKeptEnqueuerAnalyses = markFieldAsKeptEnqueuerAnalyses;
     // Tear down events.
     this.finishedAnalyses = finishedAnalyses;
     this.fixpointAnalyses = fixpointAnalyses;
@@ -346,6 +350,13 @@
     }
   }
 
+  public void processMarkFieldKept(
+      ProgramField field, KeepReason reason, EnqueuerWorklist worklist) {
+    for (MarkFieldAsKeptEnqueuerAnalysis analysis : markFieldAsKeptEnqueuerAnalyses) {
+      analysis.processNewlyKeptField(field, reason, worklist);
+    }
+  }
+
   // Tear down events.
 
   public void done(Enqueuer enqueuer) {
@@ -406,6 +417,7 @@
         new ArrayList<>();
     private final List<NewlyTargetedMethodEnqueuerAnalysis> newlyTargetedMethodAnalyses =
         new ArrayList<>();
+    private final List<MarkFieldAsKeptEnqueuerAnalysis> markFieldAsKeptAnalyses = new ArrayList<>();
 
     // Tear down events.
     private final List<FinishedEnqueuerAnalysis> finishedAnalyses = new ArrayList<>();
@@ -511,6 +523,11 @@
       return this;
     }
 
+    public Builder addMarkFieldAsKeptAnalysis(MarkFieldAsKeptEnqueuerAnalysis analysis) {
+      markFieldAsKeptAnalyses.add(analysis);
+      return this;
+    }
+
     // Tear down events.
 
     public Builder addFinishedAnalysis(FinishedEnqueuerAnalysis analysis) {
@@ -547,6 +564,7 @@
           newlyReachableFieldAnalyses.toArray(NewlyReachableFieldEnqueuerAnalysis[]::new),
           newlyReferencedFieldAnalyses.toArray(NewlyReferencedFieldEnqueuerAnalysis[]::new),
           newlyTargetedMethodAnalyses.toArray(NewlyTargetedMethodEnqueuerAnalysis[]::new),
+          markFieldAsKeptAnalyses.toArray(MarkFieldAsKeptEnqueuerAnalysis[]::new),
           // Tear down events.
           finishedAnalyses.toArray(FinishedEnqueuerAnalysis[]::new),
           fixpointAnalyses.toArray(FixpointEnqueuerAnalysis[]::new));
diff --git a/src/main/java/com/android/tools/r8/graph/analysis/MarkFieldAsKeptEnqueuerAnalysis.java b/src/main/java/com/android/tools/r8/graph/analysis/MarkFieldAsKeptEnqueuerAnalysis.java
new file mode 100644
index 0000000..efec9bc
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/graph/analysis/MarkFieldAsKeptEnqueuerAnalysis.java
@@ -0,0 +1,14 @@
+// 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.graph.analysis;
+
+import com.android.tools.r8.graph.ProgramField;
+import com.android.tools.r8.shaking.EnqueuerWorklist;
+import com.android.tools.r8.shaking.KeepReason;
+
+public interface MarkFieldAsKeptEnqueuerAnalysis {
+
+  /** Called when a field is marked as kept. */
+  void processNewlyKeptField(ProgramField field, KeepReason keepReason, EnqueuerWorklist worklist);
+}
diff --git a/src/main/java/com/android/tools/r8/graph/analysis/ResourceAccessAnalysis.java b/src/main/java/com/android/tools/r8/graph/analysis/ResourceAccessAnalysis.java
index 660504d..b0376cc 100644
--- a/src/main/java/com/android/tools/r8/graph/analysis/ResourceAccessAnalysis.java
+++ b/src/main/java/com/android/tools/r8/graph/analysis/ResourceAccessAnalysis.java
@@ -27,12 +27,14 @@
 import com.android.tools.r8.ir.conversion.MethodConversionOptions;
 import com.android.tools.r8.shaking.Enqueuer;
 import com.android.tools.r8.shaking.EnqueuerWorklist;
+import com.android.tools.r8.shaking.KeepReason;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
 import it.unimi.dsi.fastutil.ints.IntList;
 import java.util.IdentityHashMap;
 import java.util.Map;
 
-public class ResourceAccessAnalysis implements TraceFieldAccessEnqueuerAnalysis {
+public class ResourceAccessAnalysis
+    implements TraceFieldAccessEnqueuerAnalysis, MarkFieldAsKeptEnqueuerAnalysis {
 
   private final R8ResourceShrinkerState resourceShrinkerState;
   private final Map<DexType, RClassFieldToValueStore> fieldToValueMapping = new IdentityHashMap<>();
@@ -51,7 +53,9 @@
       Enqueuer enqueuer,
       EnqueuerAnalysisCollection.Builder builder) {
     if (fieldAccessAnalysisEnabled(appView, enqueuer)) {
-      builder.addTraceFieldAccessAnalysis(new ResourceAccessAnalysis(appView, enqueuer));
+      ResourceAccessAnalysis analysis = new ResourceAccessAnalysis(appView, enqueuer);
+      builder.addTraceFieldAccessAnalysis(analysis);
+      builder.addMarkFieldAsKeptAnalysis(analysis);
     }
     if (liveFieldAnalysisEnabled(appView, enqueuer)) {
       builder.addNewlyLiveFieldAnalysis(
@@ -93,12 +97,21 @@
   }
 
   @Override
+  public void processNewlyKeptField(
+      ProgramField field, KeepReason keepReason, EnqueuerWorklist worklist) {
+    processField(field);
+  }
+
+  @Override
   public void traceStaticFieldRead(
       DexField field,
       SingleFieldResolutionResult<?> resolutionResult,
       ProgramMethod context,
       EnqueuerWorklist worklist) {
-    ProgramField resolvedField = resolutionResult.getProgramField();
+    processField(resolutionResult.getProgramField());
+  }
+
+  private void processField(ProgramField resolvedField) {
     if (resolvedField == null) {
       return;
     }
@@ -109,12 +122,12 @@
       }
       assert fieldToValueMapping.containsKey(holderType);
       RClassFieldToValueStore rClassFieldToValueStore = fieldToValueMapping.get(holderType);
-      IntList integers = rClassFieldToValueStore.valueMapping.get(field);
+      IntList integers = rClassFieldToValueStore.valueMapping.get(resolvedField.getReference());
       // The R class can have fields injected, e.g., by jacoco, we don't have resource values for
       // these.
       if (integers != null) {
         for (Integer integer : integers) {
-          resourceShrinkerState.trace(integer, field.toString());
+          resourceShrinkerState.trace(integer, resolvedField.getReference().toString());
         }
       }
     }
diff --git a/src/main/java/com/android/tools/r8/shaking/Enqueuer.java b/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
index 3021e9c..15a2384 100644
--- a/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
+++ b/src/main/java/com/android/tools/r8/shaking/Enqueuer.java
@@ -4999,6 +4999,7 @@
     } else {
       worklist.enqueueMarkFieldAsReachableAction(field, field, reason);
     }
+    analyses.processMarkFieldKept(field, reason, worklist);
   }
 
   private boolean shouldMarkLibraryMethodOverrideAsReachable(LookupTarget override) {
diff --git a/src/test/java/com/android/tools/r8/androidresources/KeptOnlyRClassEntriesTest.java b/src/test/java/com/android/tools/r8/androidresources/KeptOnlyRClassEntriesTest.java
new file mode 100644
index 0000000..42c61c6
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/androidresources/KeptOnlyRClassEntriesTest.java
@@ -0,0 +1,98 @@
+// 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.androidresources;
+
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.androidresources.AndroidResourceTestingUtils.AndroidTestResource;
+import com.android.tools.r8.androidresources.AndroidResourceTestingUtils.AndroidTestResourceBuilder;
+import com.android.tools.r8.androidresources.AndroidResourceTestingUtils.TestResourceTable;
+import com.android.tools.r8.transformers.ClassTransformer;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+
+@RunWith(Parameterized.class)
+public class KeptOnlyRClassEntriesTest extends TestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection parameters() {
+    return getTestParameters().withDefaultDexRuntime().withAllApiLevels().build();
+  }
+
+  public static AndroidTestResource getTestResources(TemporaryFolder temp) throws Exception {
+    return new AndroidTestResourceBuilder()
+        .withSimpleManifestAndAppNameString()
+        .addRClassInitializeWithDefaultValues(R.drawable.class)
+        .build(temp);
+  }
+
+  @Test
+  public void testKeptField() throws Exception {
+    AndroidTestResource testResources = getTestResources(temp);
+    TestResourceTable testResourceTable = testResources.getTestResourceTable();
+    testResources
+        .getRClass()
+        .transformClassFileData(
+            new ClassTransformer() {
+              @Override
+              public FieldVisitor visitField(
+                  int access, String name, String descriptor, String signature, Object value) {
+                if (name.equals("kept_field")) {
+                  value = Integer.valueOf(testResourceTable.idFor("drawable", "kept_field"));
+                }
+                return super.visitField(access, name, descriptor, signature, value);
+              }
+
+              @Override
+              public MethodVisitor visitMethod(
+                  int access,
+                  String name,
+                  String descriptor,
+                  String signature,
+                  String[] exceptions) {
+                if (name.equals("<clinit>")) {
+                  // We are explicitly setting the value in the field visitor above
+                  return null;
+                }
+                return super.visitMethod(access, name, descriptor, signature, exceptions);
+              }
+            },
+            s -> s.contains("drawable"));
+    testForR8(parameters.getBackend())
+        .setMinApi(parameters)
+        .addProgramClasses(TestClass.class)
+        .addAndroidResources(testResources)
+        .addKeepMainRule(TestClass.class)
+        .addKeepRules("-keep class **R*drawable { int kept_field;}")
+        .enableOptimizedShrinking()
+        .compile()
+        .inspectShrunkenResources(
+            resourceTableInspector -> {
+              resourceTableInspector.assertContainsResourceWithName("drawable", "kept_field");
+            })
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccess();
+  }
+
+  public static class TestClass {
+    public static void main(String[] args) {}
+  }
+
+  public static class R {
+
+    public static class drawable {
+      public static int kept_field = 0x7f010001;
+    }
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/androidresources/RClassResourceGeneration.java b/src/test/java/com/android/tools/r8/androidresources/RClassResourceGeneration.java
index 9f5f557..4d2a0e8 100644
--- a/src/test/java/com/android/tools/r8/androidresources/RClassResourceGeneration.java
+++ b/src/test/java/com/android/tools/r8/androidresources/RClassResourceGeneration.java
@@ -71,7 +71,7 @@
   public void testResourceRewriting() throws Exception {
     AndroidApp resourceApp =
         AndroidApp.builder()
-            .addClassProgramData(testResource.getRClass().getClassFileData())
+            .addClassProgramData(testResource.getRClass().getClassFileData().values())
             .build();
     CodeInspector inspector = new CodeInspector(resourceApp);
     ClassSubject stringClazz = inspector.clazz(R.string.class);
diff --git a/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java b/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
index 3d46d18..edd0bbf 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
@@ -1043,16 +1043,16 @@
 
               return featureSplitGenerator.build();
             });
-    return addProgramClassFileData(testResource.getRClass().getClassFileData());
+    return addProgramClassFileData(testResource.getRClass().getClassFileData().values());
   }
 
   public T addAndroidResources(AndroidTestResource testResource, Path output) throws IOException {
-    List<byte[]> classFileData = testResource.getRClass().getClassFileData();
+    Collection<byte[]> classFileData = testResource.getRClass().getClassFileData().values();
     return addAndroidResources(testResource, output, classFileData);
   }
 
   public T addAndroidResources(
-      AndroidTestResource testResource, Path output, List<byte[]> classFileData) {
+      AndroidTestResource testResource, Path output, Collection<byte[]> classFileData) {
     Path resources = testResource.getResourceZip();
     resourceShrinkerOutput = output;
     ArchiveProtoAndroidResourceProvider provider = getResourceProvider(testResource);
diff --git a/src/test/testbase/java/com/android/tools/r8/androidresources/AndroidResourceTestingUtils.java b/src/test/testbase/java/com/android/tools/r8/androidresources/AndroidResourceTestingUtils.java
index d55db2f..f2e8b29 100644
--- a/src/test/testbase/java/com/android/tools/r8/androidresources/AndroidResourceTestingUtils.java
+++ b/src/test/testbase/java/com/android/tools/r8/androidresources/AndroidResourceTestingUtils.java
@@ -9,13 +9,16 @@
 import com.android.aapt.Resources;
 import com.android.aapt.Resources.ConfigValue;
 import com.android.aapt.Resources.Item;
+import com.android.aapt.Resources.Package;
 import com.android.aapt.Resources.ResourceTable;
+import com.android.build.shrinker.ResourceTableUtilKt;
 import com.android.tools.r8.TestBase;
 import com.android.tools.r8.TestBase.Backend;
 import com.android.tools.r8.TestRuntime.CfRuntime;
 import com.android.tools.r8.ToolHelper;
 import com.android.tools.r8.ToolHelper.ProcessResult;
 import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.transformers.ClassFileTransformer;
 import com.android.tools.r8.transformers.ClassTransformer;
 import com.android.tools.r8.transformers.MethodTransformer;
 import com.android.tools.r8.utils.AndroidApiLevel;
@@ -24,6 +27,7 @@
 import com.android.tools.r8.utils.StreamUtils;
 import com.android.tools.r8.utils.StringUtils;
 import com.android.tools.r8.utils.ZipUtils;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.MoreCollectors;
 import com.google.protobuf.InvalidProtocolBufferException;
@@ -36,6 +40,7 @@
 import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -44,6 +49,7 @@
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
@@ -160,9 +166,9 @@
     private final Path javaFilePath;
     // The compiled class files, with the class names rewritten to the names used in passed
     // in R class from the test.
-    private final List<byte[]> classFileData;
+    private final Map<String, byte[]> classFileData;
 
-    AndroidTestRClass(Path javaFilePath, List<byte[]> classFileData) throws IOException {
+    AndroidTestRClass(Path javaFilePath, Map<String, byte[]> classFileData) throws IOException {
       this.javaFilePath = javaFilePath;
       this.classFileData = classFileData;
     }
@@ -170,9 +176,26 @@
       return javaFilePath;
     }
 
-    public List<byte[]> getClassFileData() {
+    public Map<String, byte[]> getClassFileData() {
       return classFileData;
     }
+
+    public void transformClassFileData(ClassTransformer transformer) {
+      transformClassFileData(transformer, s -> true);
+    }
+
+    public void transformClassFileData(ClassTransformer transformer, Predicate<String> include) {
+      Map<String, byte[]> changed = new HashMap<>();
+      for (Entry<String, byte[]> entry : classFileData.entrySet()) {
+        if (include.test(entry.getKey())) {
+          byte[] transform =
+              ClassFileTransformer.transform(
+                  entry.getValue(), ImmutableList.of(transformer), Collections.EMPTY_LIST, 0);
+          changed.put(entry.getKey(), transform);
+        }
+      }
+      classFileData.putAll(changed);
+    }
   }
 
   public static class AndroidTestResource {
@@ -209,6 +232,11 @@
       return this;
     }
 
+    public TestResourceTable getTestResourceTable() throws IOException {
+      return new TestResourceTable(
+          ResourceTable.parseFrom(ZipUtils.readSingleEntry(resourceZip, "resources.pb")));
+    }
+
     public AndroidTestRClass getRClass() {
       return rClass;
     }
@@ -224,14 +252,17 @@
 
   // Easy traversable resource table.
   public static class TestResourceTable {
-    private Map<String, ResourceNameToValueMapping> mapping = new HashMap<>();
+    private Map<String, ResourceNameToValueMapping> valueMapping = new HashMap<>();
+    private Map<String, ResourceNameToIdMapping> idMapping = new HashMap<>();
 
     private TestResourceTable(ResourceTable resourceTable) {
       // For now, we don't have any test that use multiple packages.
       assert resourceTable.getPackageCount() == 1;
-      for (Resources.Type type : resourceTable.getPackage(0).getTypeList()) {
+      Package aPackage = resourceTable.getPackage(0);
+      for (Resources.Type type : aPackage.getTypeList()) {
         String typeName = type.getName();
-        mapping.put(typeName, new ResourceNameToValueMapping(type));
+        valueMapping.put(typeName, new ResourceNameToValueMapping(type));
+        idMapping.put(typeName, new ResourceNameToIdMapping(type, aPackage));
       }
     }
 
@@ -240,11 +271,29 @@
     }
 
     public boolean containsValueFor(String type, String name) {
-      return mapping.containsKey(type) && mapping.get(type).containsValueFor(name);
+      return valueMapping.containsKey(type) && valueMapping.get(type).containsValueFor(name);
+    }
+
+    public int idFor(String type, String name) {
+      return idMapping.get(type).getIdFor(name);
     }
 
     public Collection<String> entriesForType(String type) {
-      return mapping.get(type).mapping.keySet();
+      return valueMapping.get(type).mapping.keySet();
+    }
+
+    public static class ResourceNameToIdMapping {
+      private final Map<String, Integer> mapping = new HashMap<>();
+
+      public ResourceNameToIdMapping(Resources.Type type, Package aPackage) {
+        for (Resources.Entry entry : type.getEntryList()) {
+          mapping.put(entry.getName(), ResourceTableUtilKt.toIdentifier(aPackage, type, entry));
+        }
+      }
+
+      public int getIdFor(String name) {
+        return mapping.get(name);
+      }
     }
 
     public static class ResourceNameToValueMapping {
@@ -541,12 +590,13 @@
                   Collectors.toMap(
                       AndroidResourceTestingUtils::rClassWithoutNamespaceAndOuter,
                       DescriptorUtils::getClassBinaryName));
-      List<byte[]> rewrittenRClassFiles = new ArrayList<>();
+      Map<String, byte[]> rewrittenRClassFiles = new HashMap<>();
       ZipUtils.iter(
           rClassClassFileOutput,
           (entry, input) -> {
             if (ZipUtils.isClassFile(entry.getName()) && !entry.getName().endsWith("R.class")) {
-              rewrittenRClassFiles.add(
+              rewrittenRClassFiles.put(
+                  entry.getName(),
                   transformer(StreamUtils.streamToByteArrayClose(input), null)
                       .addClassTransformer(
                           new ClassTransformer() {