Merge "Add SpeculativeLoadingConfig API" into androidx-main
diff --git a/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java
new file mode 100644
index 0000000..5f09ac3
--- /dev/null
+++ b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/PrefetchTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.webkit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+import androidx.webkit.test.common.WebkitUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PrefetchTest {
+
+    /**
+     * Test setting valid values for
+     * {@link SpeculativeLoadingConfig.Builder#setPrefetchTtlSeconds(int)}
+     */
+    @Test
+    public void testTTLValidValues() {
+        WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+        SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+        // lower values
+        builder.setPrefetchTtlSeconds(1);
+        assertEquals(1, builder.build().getPrefetchTtlSeconds());
+
+        builder.setPrefetchTtlSeconds(Integer.MAX_VALUE - 1);
+        assertEquals(Integer.MAX_VALUE - 1, builder.build().getMaxPrefetches());
+
+        builder.setPrefetchTtlSeconds(5685);
+        assertEquals(5685, builder.build().getMaxPrefetches());
+    }
+
+    /**
+     * Test setting valid values for {@link SpeculativeLoadingConfig.Builder#setMaxPrefetches(int)}
+     */
+    @Test
+    public void testMaxPrefetchesValidValues() {
+        WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+        SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+        builder.setMaxPrefetches(1);
+        assertEquals(1, builder.build().getMaxPrefetches());
+
+        builder.setPrefetchTtlSeconds(SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES);
+        assertEquals(SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES,
+                builder.build().getMaxPrefetches());
+    }
+
+    /**
+     * Test setting out-of-range values for
+     * {@link SpeculativeLoadingConfig.Builder#setPrefetchTtlSeconds(int)}
+     */
+    @Test
+    public void testTTLLimit() {
+        WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+        SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+
+        IllegalArgumentException expectedException = assertThrows(IllegalArgumentException.class,
+                () -> builder.setPrefetchTtlSeconds(0));
+        assertEquals("Prefetch TTL must be greater than 0", expectedException.getMessage());
+    }
+
+    /**
+     * Test setting out-of-range values for
+     * {@link SpeculativeLoadingConfig.Builder#setMaxPrefetches(int)}
+     */
+    @Test
+    public void testMaxPrefetchesLimit() {
+        WebkitUtils.checkFeature(WebViewFeature.SPECULATIVE_LOADING_CONFIG);
+        SpeculativeLoadingConfig.Builder builder = new SpeculativeLoadingConfig.Builder();
+
+        // lower bound
+        IllegalArgumentException expectedException = assertThrows(IllegalArgumentException.class,
+                () -> builder.setMaxPrefetches(0));
+        assertEquals("Max prefetches must be greater than 0", expectedException.getMessage());
+
+        // upper bound
+        expectedException = assertThrows(IllegalArgumentException.class,
+                () -> builder.setMaxPrefetches(
+                        SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES + 1));
+        assertEquals("Max prefetches cannot exceed"
+                + SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES, expectedException.getMessage());
+    }
+
+}
diff --git a/webkit/webkit/api/current.txt b/webkit/webkit/api/current.txt
index 77fa0ba..23ee69f 100644
--- a/webkit/webkit/api/current.txt
+++ b/webkit/webkit/api/current.txt
@@ -80,6 +80,7 @@
     method @AnyThread @RequiresFeature(name=androidx.webkit.WebViewFeature.MULTI_PROFILE, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public android.webkit.WebStorage getWebStorage();
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.SpeculativeLoadingParameters, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
+    method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.SPECULATIVE_LOADING_CONFIG, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void setSpeculativeLoadingConfig(androidx.webkit.SpeculativeLoadingConfig);
     field public static final String DEFAULT_PROFILE_NAME = "Default";
   }
 
@@ -162,6 +163,21 @@
     method @RequiresFeature(name="REQUESTED_WITH_HEADER_ALLOW_LIST", enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public abstract void setRequestedWithHeaderOriginAllowList(java.util.Set<java.lang.String!>);
   }
 
+  @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public class SpeculativeLoadingConfig {
+    method @IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) public int getMaxPrefetches();
+    method @IntRange(from=1, to=java.lang.Integer.MAX_VALUE) public int getPrefetchTtlSeconds();
+    field public static final int ABSOLUTE_MAX_PREFETCHES = 20; // 0x14
+    field public static final int DEFAULT_MAX_PREFETCHES = 10; // 0xa
+    field public static final int DEFAULT_TTL_SECS = 60; // 0x3c
+  }
+
+  @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final class SpeculativeLoadingConfig.Builder {
+    ctor public SpeculativeLoadingConfig.Builder();
+    method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.SpeculativeLoadingConfig build();
+    method public androidx.webkit.SpeculativeLoadingConfig.Builder setMaxPrefetches(@IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) int);
+    method public androidx.webkit.SpeculativeLoadingConfig.Builder setPrefetchTtlSeconds(@IntRange(from=1, to=java.lang.Integer.MAX_VALUE) int);
+  }
+
   @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class SpeculativeLoadingParameters {
     method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
     method public androidx.webkit.NoVarySearchHeader? getExpectedNoVarySearchData();
@@ -484,6 +500,7 @@
     field public static final String SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST = "SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST";
     field public static final String SHOULD_OVERRIDE_WITH_REDIRECTS = "SHOULD_OVERRIDE_WITH_REDIRECTS";
     field public static final String SPECULATIVE_LOADING = "SPECULATIVE_LOADING_STATUS";
+    field @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
     field public static final String STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES = "STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES";
     field public static final String STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX = "STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX";
     field public static final String STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS = "STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS";
diff --git a/webkit/webkit/api/restricted_current.txt b/webkit/webkit/api/restricted_current.txt
index 77fa0ba..23ee69f 100644
--- a/webkit/webkit/api/restricted_current.txt
+++ b/webkit/webkit/api/restricted_current.txt
@@ -80,6 +80,7 @@
     method @AnyThread @RequiresFeature(name=androidx.webkit.WebViewFeature.MULTI_PROFILE, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public android.webkit.WebStorage getWebStorage();
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void prefetchUrlAsync(String, android.os.CancellationSignal?, java.util.concurrent.Executor, androidx.webkit.SpeculativeLoadingParameters, androidx.webkit.OutcomeReceiverCompat<java.lang.Void!,androidx.webkit.PrefetchException!>);
+    method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.SPECULATIVE_LOADING_CONFIG, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @UiThread @androidx.webkit.Profile.ExperimentalUrlPrefetch public void setSpeculativeLoadingConfig(androidx.webkit.SpeculativeLoadingConfig);
     field public static final String DEFAULT_PROFILE_NAME = "Default";
   }
 
@@ -162,6 +163,21 @@
     method @RequiresFeature(name="REQUESTED_WITH_HEADER_ALLOW_LIST", enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") public abstract void setRequestedWithHeaderOriginAllowList(java.util.Set<java.lang.String!>);
   }
 
+  @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public class SpeculativeLoadingConfig {
+    method @IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) public int getMaxPrefetches();
+    method @IntRange(from=1, to=java.lang.Integer.MAX_VALUE) public int getPrefetchTtlSeconds();
+    field public static final int ABSOLUTE_MAX_PREFETCHES = 20; // 0x14
+    field public static final int DEFAULT_MAX_PREFETCHES = 10; // 0xa
+    field public static final int DEFAULT_TTL_SECS = 60; // 0x3c
+  }
+
+  @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final class SpeculativeLoadingConfig.Builder {
+    ctor public SpeculativeLoadingConfig.Builder();
+    method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.SpeculativeLoadingConfig build();
+    method public androidx.webkit.SpeculativeLoadingConfig.Builder setMaxPrefetches(@IntRange(from=1, to=androidx.webkit.SpeculativeLoadingConfig.ABSOLUTE_MAX_PREFETCHES) int);
+    method public androidx.webkit.SpeculativeLoadingConfig.Builder setPrefetchTtlSeconds(@IntRange(from=1, to=java.lang.Integer.MAX_VALUE) int);
+  }
+
   @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class SpeculativeLoadingParameters {
     method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
     method public androidx.webkit.NoVarySearchHeader? getExpectedNoVarySearchData();
@@ -484,6 +500,7 @@
     field public static final String SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST = "SERVICE_WORKER_SHOULD_INTERCEPT_REQUEST";
     field public static final String SHOULD_OVERRIDE_WITH_REDIRECTS = "SHOULD_OVERRIDE_WITH_REDIRECTS";
     field public static final String SPECULATIVE_LOADING = "SPECULATIVE_LOADING_STATUS";
+    field @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
     field public static final String STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES = "STARTUP_FEATURE_CONFIGURE_PARTITIONED_COOKIES";
     field public static final String STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX = "STARTUP_FEATURE_SET_DATA_DIRECTORY_SUFFIX";
     field public static final String STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS = "STARTUP_FEATURE_SET_DIRECTORY_BASE_PATHS";
diff --git a/webkit/webkit/src/main/java/androidx/webkit/Profile.java b/webkit/webkit/src/main/java/androidx/webkit/Profile.java
index ec6fcb1..370e930 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/Profile.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/Profile.java
@@ -236,4 +236,22 @@
             @NonNull Executor callbackExecutor,
             @NonNull OutcomeReceiverCompat<Void, PrefetchException> operationCallback);
 
+    /**
+     * Sets the {@link SpeculativeLoadingConfig} for the current profile session.
+     * These configurations will be applied to any Prefetch requests made after they are set;
+     * they will not be applied to in-flight requests.
+     * <p>
+     * These configurations will be applied to any prefetch requests initiated by
+     * a prerender request. This applies specifically to WebViews that are
+     * associated with this Profile.
+     * <p>
+     * @param speculativeLoadingConfig the config to set for this profile session.
+     */
+    @RequiresFeature(name = WebViewFeature.SPECULATIVE_LOADING_CONFIG,
+            enforcement = "androidx.webkit.WebViewFeature#isFeatureSupported")
+    @UiThread
+    @ExperimentalUrlPrefetch
+    void setSpeculativeLoadingConfig(@NonNull SpeculativeLoadingConfig
+            speculativeLoadingConfig);
+
 }
diff --git a/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java b/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java
new file mode 100644
index 0000000..51ef8bd
--- /dev/null
+++ b/webkit/webkit/src/main/java/androidx/webkit/SpeculativeLoadingConfig.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.webkit;
+
+import androidx.annotation.IntRange;
+
+import org.jspecify.annotations.NonNull;
+
+/**
+ * Represents a configuration for speculative loading in a {@link Profile} instance. This should
+ * be set using {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+ */
[email protected]
+public class SpeculativeLoadingConfig {
+
+    /**
+     * The absolute maximum number of prefetches allowed in cache.
+     */
+    public static final int ABSOLUTE_MAX_PREFETCHES = 20;
+
+    /**
+     * The default Time-to-Live (TTL) in seconds for prefetched data.
+     */
+    public static final int DEFAULT_TTL_SECS = 60;
+
+    /**
+     * The default number of prefetches allowed in cache.
+     */
+    public static final int DEFAULT_MAX_PREFETCHES = 10;
+
+    private final int mPrefetchTTLSeconds;
+
+    private final int mMaxPrefetches;
+
+    /**
+     * Private constructors, the application will need to use
+     * {@link Builder} for constructing instances of
+     * this class.
+     */
+    private SpeculativeLoadingConfig(int ttlSecs, int max) {
+        mPrefetchTTLSeconds = ttlSecs;
+        mMaxPrefetches = max;
+    }
+
+    /**
+     * The "time to live" for a prefetch inside of the prefetch cache.
+     * This is representative of the maximum time that a prefetch is considered
+     * valid and can be served to a navigation. This value is in seconds and
+     * defaults to {@link SpeculativeLoadingConfig#DEFAULT_TTL_SECS}.
+     */
+    @IntRange(from = 1, to = Integer.MAX_VALUE)
+    public int getPrefetchTtlSeconds() {
+        return mPrefetchTTLSeconds;
+    }
+
+    /**
+     * The max amount of prefetches that can live in the cache. Defaults to
+     * {@link SpeculativeLoadingConfig#DEFAULT_MAX_PREFETCHES}.
+     * <p>
+     * Cannot exceed {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+     */
+    @IntRange(from = 1, to = ABSOLUTE_MAX_PREFETCHES)
+    public int getMaxPrefetches() {
+        return mMaxPrefetches;
+    }
+
+    @Profile.ExperimentalUrlPrefetch
+    public static final class Builder {
+        private int mPrefetchTTLSeconds = DEFAULT_TTL_SECS;
+        private int mMaxPrefetches = DEFAULT_MAX_PREFETCHES;
+
+        public Builder() {
+        }
+
+        /**
+         * Sets the Time-to-Live (TTL) in seconds for prefetched data.
+         * <p>
+         * This value determines how long prefetched data will be considered valid before it is
+         * refreshed.
+         *
+         * @param ttlSeconds The TTL value in seconds. Must be a positive integer.
+         * @return This builder instance for method chaining.
+         * @throws IllegalArgumentException If {@code ttlSeconds} is less than 1.
+         * @see Builder#build()
+         */
+        @NonNull
+        public Builder setPrefetchTtlSeconds(
+                @IntRange(from = 1, to = Integer.MAX_VALUE) int ttlSeconds) {
+            if (ttlSeconds <= 0) {
+                throw new IllegalArgumentException("Prefetch TTL must be greater than 0");
+            }
+            mPrefetchTTLSeconds = ttlSeconds;
+            return this;
+        }
+
+        /**
+         * Sets the maximum number of allowed prefetches.
+         *
+         * <p>
+         * This value limits the number of prefetch data that can live in the cache.
+         *
+         * @param max The maximum number of prefetches. Must be a positive integer and not exceed
+         *            {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+         * @return This builder instance for method chaining.
+         * @throws IllegalArgumentException If {@code max} is less than 1 or greater than
+         *                                 {@link SpeculativeLoadingConfig#ABSOLUTE_MAX_PREFETCHES}.
+         * @see Builder#build()
+         */
+        @NonNull
+        public Builder setMaxPrefetches(@IntRange(from = 1, to = ABSOLUTE_MAX_PREFETCHES) int max) {
+            if (max > ABSOLUTE_MAX_PREFETCHES) {
+                String error = "Max prefetches cannot exceed" + ABSOLUTE_MAX_PREFETCHES;
+                throw new IllegalArgumentException(error);
+            }
+
+            if (max < 1) {
+                throw new IllegalArgumentException("Max prefetches must be greater than 0");
+            }
+            mMaxPrefetches = max;
+            return this;
+        }
+
+        /**
+         * Builds a new {@link SpeculativeLoadingConfig} instance.
+         * <p>
+         * This method creates a new {@link SpeculativeLoadingConfig} object using the parameters
+         * that have been set in this builder.
+         *
+         * @return A new {@link SpeculativeLoadingConfig} instance.
+         */
+        @Profile.ExperimentalUrlPrefetch
+        @NonNull
+        public SpeculativeLoadingConfig build() {
+            return new SpeculativeLoadingConfig(mPrefetchTTLSeconds, mMaxPrefetches);
+        }
+    }
+}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
index 2ff0751..18747e3 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
@@ -660,6 +660,14 @@
     public static final String PRERENDER_WITH_URL = "PRERENDER_URL";
 
     /**
+     * Feature for {@link #isFeatureSupported(String)}.
+     * This feature covers
+     * {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+     */
+    @Profile.ExperimentalUrlPrefetch
+    public static final String SPECULATIVE_LOADING_CONFIG = "SPECULATIVE_LOADING_CONFIG";
+
+    /**
      * Return whether a feature is supported at run-time. This will check whether a feature is
      * supported, depending on the combination of the desired feature, the Android version of
      * device, and the WebView APK on the device.
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java b/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
index dd61055..47c026a 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/ProfileImpl.java
@@ -25,6 +25,7 @@
 import androidx.webkit.OutcomeReceiverCompat;
 import androidx.webkit.PrefetchException;
 import androidx.webkit.Profile;
+import androidx.webkit.SpeculativeLoadingConfig;
 import androidx.webkit.SpeculativeLoadingParameters;
 
 import org.chromium.support_lib_boundary.ProfileBoundaryInterface;
@@ -152,4 +153,18 @@
         }
     }
 
+    @Override
+    public void setSpeculativeLoadingConfig(
+            @NonNull SpeculativeLoadingConfig speculativeLoadingConfig) {
+        ApiFeature.NoFramework feature = WebViewFeatureInternal.SPECULATIVE_LOADING_CONFIG;
+        if (feature.isSupportedByWebView()) {
+            InvocationHandler configInvocation =
+                    BoundaryInterfaceReflectionUtil.createInvocationHandlerFor(
+                            new SpeculativeLoadingConfigAdapter(speculativeLoadingConfig));
+            mProfileImpl.setSpeculativeLoadingConfig(configInvocation);
+        } else {
+            throw WebViewFeatureInternal.getUnsupportedOperationException();
+        }
+    }
+
 }
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java b/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java
new file mode 100644
index 0000000..a2a0501
--- /dev/null
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/SpeculativeLoadingConfigAdapter.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.webkit.internal;
+
+import androidx.webkit.SpeculativeLoadingConfig;
+
+import org.chromium.support_lib_boundary.SpeculativeLoadingConfigBoundaryInterface;
+import org.jspecify.annotations.NonNull;
+
+public class SpeculativeLoadingConfigAdapter implements SpeculativeLoadingConfigBoundaryInterface {
+    private final SpeculativeLoadingConfig mSpeculativeLoadingConfig;
+
+    public SpeculativeLoadingConfigAdapter(@NonNull SpeculativeLoadingConfig config) {
+        mSpeculativeLoadingConfig = config;
+    }
+
+    @Override
+    public int getMaxPrefetches() {
+        return mSpeculativeLoadingConfig.getMaxPrefetches();
+    }
+
+    @Override
+    public int getPrefetchTTLSeconds() {
+        return mSpeculativeLoadingConfig.getPrefetchTtlSeconds();
+    }
+}
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java b/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
index dc818ef..fd040be 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/WebViewFeatureInternal.java
@@ -37,6 +37,7 @@
 import androidx.webkit.ProxyController;
 import androidx.webkit.SafeBrowsingResponseCompat;
 import androidx.webkit.ServiceWorkerClientCompat;
+import androidx.webkit.SpeculativeLoadingConfig;
 import androidx.webkit.SpeculativeLoadingParameters;
 import androidx.webkit.TracingConfig;
 import androidx.webkit.TracingController;
@@ -695,6 +696,13 @@
             new ApiFeature.NoFramework(WebViewFeature.PRERENDER_WITH_URL,
                     Features.PRERENDER_WITH_URL);
 
+    /**
+     * Feature for {@link WebViewFeature#isFeatureSupported(String)}.
+     * This feature covers {@link Profile#setSpeculativeLoadingConfig(SpeculativeLoadingConfig)}
+     */
+    public static final ApiFeature.NoFramework SPECULATIVE_LOADING_CONFIG =
+            new ApiFeature.NoFramework(WebViewFeature.SPECULATIVE_LOADING_CONFIG,
+                    Features.SPECULATIVE_LOADING_CONFIG);
 
     // --- Add new feature constants above this line ---