From 03b87c5d21ab9db029aa7ee2d72799ae01324ebb Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Sat, 5 Sep 2026 22:30:30 +0800 Subject: [PATCH 1/2] fix(api): isolate interceptor state across reinitialization --- .../InterceptorChainInstrumentedTest.java | 10 ++- ...nterceptorServiceImplInstrumentedTest.java | 84 +++++++++++++++++++ .../arouter/core/InterceptorServiceImpl.java | 81 +++++++++--------- .../android/arouter/core/Warehouse.java | 4 - 4 files changed, 132 insertions(+), 47 deletions(-) diff --git a/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorChainInstrumentedTest.java b/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorChainInstrumentedTest.java index 5ce90460a..667ef8827 100644 --- a/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorChainInstrumentedTest.java +++ b/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorChainInstrumentedTest.java @@ -345,10 +345,12 @@ public void onInterrupt(Throwable exception) { assertEquals(0, continued.get()); assertEquals(navigationCount, interrupted.get()); - Warehouse.interceptors.clear(); - Warehouse.interceptors.add(new CountingInterceptor()); + Warehouse.clear(); + Warehouse.interceptorsIndex.put(1, CountingInterceptor.class); + InterceptorServiceImpl subsequentService = new InterceptorServiceImpl(); + subsequentService.init(InstrumentationRegistry.getInstrumentation().getTargetContext()); RecordingCallback subsequentNavigation = new RecordingCallback(); - service.doInterceptions(new Postcard("/test/subsequent", "test").setTimeout(2), subsequentNavigation); + subsequentService.doInterceptions(new Postcard("/test/subsequent", "test").setTimeout(2), subsequentNavigation); assertTrue(subsequentNavigation.await(1, TimeUnit.SECONDS)); assertEquals(1, subsequentNavigation.continueCount.get()); @@ -478,7 +480,7 @@ public void init(Context context) { } } - private static final class CountingInterceptor implements IInterceptor { + public static final class CountingInterceptor implements IInterceptor { private final AtomicInteger processCount = new AtomicInteger(); @Override diff --git a/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorServiceImplInstrumentedTest.java b/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorServiceImplInstrumentedTest.java index 91bbf0b4e..639e63d92 100644 --- a/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorServiceImplInstrumentedTest.java +++ b/arouter-api/src/androidTest/java/com/alibaba/android/arouter/core/InterceptorServiceImplInstrumentedTest.java @@ -84,6 +84,87 @@ public void successfulInitializationRunsTheCompleteInterceptorChain() throws Exc assertEquals(2, PassingInterceptor.processCount.get()); } + @Test + public void retiredInitializationDoesNotModifyTheReplacementService() throws Exception { + ThreadPoolExecutor retiringExecutor = new ThreadPoolExecutor( + 1, 1, 1, TimeUnit.SECONDS, new LinkedBlockingQueue() + ); + try { + Warehouse.interceptorsIndex.put(1, BlockingInitInterceptor.class); + LogisticsCenter.executor = retiringExecutor; + new InterceptorServiceImpl().init(targetContext()); + assertTrue(BlockingInitInterceptor.awaitInitializationStart()); + + // Debug destroy/re-init clears the registry while the old initializer can still run. + Warehouse.clear(); + Warehouse.interceptorsIndex.put(1, PassingInterceptor.class); + LogisticsCenter.executor = testExecutor; + InterceptorServiceImpl replacement = new InterceptorServiceImpl(); + replacement.init(targetContext()); + + RecordingCallback firstCallback = new RecordingCallback(); + replacement.doInterceptions(new Postcard("/test/replacement/first", "test"), firstCallback); + assertTrue(firstCallback.await()); + assertNull(firstCallback.interrupted.get()); + assertEquals(1, PassingInterceptor.processCount.get()); + + BlockingInitInterceptor.releaseInitialization(); + retiringExecutor.shutdown(); + assertTrue(retiringExecutor.awaitTermination(5, TimeUnit.SECONDS)); + + PassingInterceptor.reset(); + RecordingCallback secondCallback = new RecordingCallback(); + Postcard postcard = new Postcard("/test/replacement/second", "test"); + replacement.doInterceptions(postcard, secondCallback); + assertTrue(secondCallback.await()); + assertSame(postcard, secondCallback.continued.get()); + assertNull(secondCallback.interrupted.get()); + assertEquals(1, PassingInterceptor.processCount.get()); + assertEquals(0, BlockingInitInterceptor.processCount.get()); + } finally { + BlockingInitInterceptor.releaseInitialization(); + retiringExecutor.shutdownNow(); + LogisticsCenter.executor = testExecutor; + assertTrue(retiringExecutor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void registryResetDoesNotSkipTheInitializingServicesInterceptors() throws Exception { + Warehouse.interceptorsIndex.put(1, BlockingInitInterceptor.class); + Warehouse.interceptorsIndex.put(2, PassingInterceptor.class); + InterceptorServiceImpl service = new InterceptorServiceImpl(); + service.init(targetContext()); + assertTrue(BlockingInitInterceptor.awaitInitializationStart()); + + Warehouse.clear(); + BlockingInitInterceptor.releaseInitialization(); + RecordingCallback callback = new RecordingCallback(); + Postcard postcard = new Postcard("/test/registry-reset", "test"); + service.doInterceptions(postcard, callback); + + assertTrue(callback.await()); + assertSame(postcard, callback.continued.get()); + assertNull(callback.interrupted.get()); + assertEquals(1, BlockingInitInterceptor.processCount.get()); + assertEquals(1, PassingInterceptor.processCount.get()); + } + + @Test + public void emptyRegistryContinuesWithoutSchedulingWork() { + testExecutor.shutdown(); + InterceptorServiceImpl service = new InterceptorServiceImpl(); + service.init(targetContext()); + RecordingCallback callback = new RecordingCallback(); + Postcard postcard = new Postcard("/test/no-interceptors", "test"); + + service.doInterceptions(postcard, callback); + + assertSame(postcard, callback.continued.get()); + assertNull(callback.interrupted.get()); + assertEquals(0, testExecutor.getTaskCount()); + } + @Test public void initializationFailureInterruptsNavigationWithTheOriginalCause() throws Exception { Warehouse.interceptorsIndex.put(1, FailingInitInterceptor.class); @@ -269,10 +350,12 @@ public void init(Context context) { } public static class BlockingInitInterceptor implements IInterceptor { + static final AtomicInteger processCount = new AtomicInteger(); private static CountDownLatch initializationStarted; private static CountDownLatch initializationRelease; static void reset() { + processCount.set(0); initializationStarted = new CountDownLatch(1); initializationRelease = new CountDownLatch(1); } @@ -287,6 +370,7 @@ static void releaseInitialization() { @Override public void process(Postcard postcard, InterceptorCallback callback) { + processCount.incrementAndGet(); callback.onContinue(postcard); } diff --git a/arouter-api/src/main/java/com/alibaba/android/arouter/core/InterceptorServiceImpl.java b/arouter-api/src/main/java/com/alibaba/android/arouter/core/InterceptorServiceImpl.java index a9d93d681..e82970907 100644 --- a/arouter-api/src/main/java/com/alibaba/android/arouter/core/InterceptorServiceImpl.java +++ b/arouter-api/src/main/java/com/alibaba/android/arouter/core/InterceptorServiceImpl.java @@ -8,9 +8,9 @@ import com.alibaba.android.arouter.facade.callback.InterceptorCallback; import com.alibaba.android.arouter.facade.service.InterceptorService; import com.alibaba.android.arouter.facade.template.IInterceptor; -import com.alibaba.android.arouter.utils.MapUtils; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; import static com.alibaba.android.arouter.launcher.ARouter.logger; @@ -27,30 +27,31 @@ public class InterceptorServiceImpl implements InterceptorService { private static final long INTERCEPTOR_INIT_TIMEOUT_SECONDS = 10; private final InterceptorInitState interceptorInitState = new InterceptorInitState(); + private final List interceptors = new ArrayList<>(); @Override public void doInterceptions(final Postcard postcard, final InterceptorCallback callback) { - if (MapUtils.isNotEmpty(Warehouse.interceptorsIndex)) { - InterceptorInitState.Result initResult; - try { - initResult = interceptorInitState.await(INTERCEPTOR_INIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - HandlerException interruption = new HandlerException("Interceptor initialization was interrupted."); - interruption.initCause(ex); - callback.onInterrupt(interruption); - return; - } + InterceptorInitState.Result initResult; + try { + initResult = interceptorInitState.await(INTERCEPTOR_INIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + HandlerException interruption = new HandlerException("Interceptor initialization was interrupted."); + interruption.initCause(ex); + callback.onInterrupt(interruption); + return; + } - if (initResult.getOutcome() == InterceptorInitState.Outcome.TIMEOUT) { - callback.onInterrupt(new HandlerException("Interceptors initialization takes too much time.")); - return; - } else if (initResult.getOutcome() == InterceptorInitState.Outcome.FAILURE) { - callback.onInterrupt(initResult.getFailure()); - return; - } + if (initResult.getOutcome() == InterceptorInitState.Outcome.TIMEOUT) { + callback.onInterrupt(new HandlerException("Interceptors initialization takes too much time.")); + return; + } else if (initResult.getOutcome() == InterceptorInitState.Outcome.FAILURE) { + callback.onInterrupt(initResult.getFailure()); + return; + } - InterceptorChain interceptorChain = new InterceptorChain(Warehouse.interceptors, postcard, callback); + if (!interceptors.isEmpty()) { + InterceptorChain interceptorChain = new InterceptorChain(interceptors, postcard, callback); try { interceptorChain.scheduleTimeout(postcard.getTimeout(), TimeUnit.SECONDS); LogisticsCenter.executor.execute(interceptorChain); @@ -65,30 +66,32 @@ public void doInterceptions(final Postcard postcard, final InterceptorCallback c @Override public void init(final Context context) { interceptorInitState.start(); + // An old async initializer must not read or modify a later debug re-init's registry. + final List> interceptorClasses = + new ArrayList<>(Warehouse.interceptorsIndex.values()); + if (interceptorClasses.isEmpty()) { + interceptorInitState.succeed(); + return; + } try { LogisticsCenter.executor.execute(new Runnable() { @Override public void run() { - if (MapUtils.isNotEmpty(Warehouse.interceptorsIndex)) { - for (Map.Entry> entry : Warehouse.interceptorsIndex.entrySet()) { - Class interceptorClass = entry.getValue(); - try { - IInterceptor iInterceptor = interceptorClass.getConstructor().newInstance(); - iInterceptor.init(context); - Warehouse.interceptors.add(iInterceptor); - } catch (Exception ex) { - HandlerException failure = interceptorInitFailure(interceptorClass, ex); - interceptorInitState.fail(failure); - logger.error(TAG, failure.getMessage(), ex); - return; - } + for (Class interceptorClass : interceptorClasses) { + try { + IInterceptor iInterceptor = interceptorClass.getConstructor().newInstance(); + iInterceptor.init(context); + interceptors.add(iInterceptor); + } catch (Exception ex) { + HandlerException failure = interceptorInitFailure(interceptorClass, ex); + interceptorInitState.fail(failure); + logger.error(TAG, failure.getMessage(), ex); + return; } - - interceptorInitState.succeed(); - logger.info(TAG, "ARouter interceptors init over."); - } else { - interceptorInitState.succeed(); } + + interceptorInitState.succeed(); + logger.info(TAG, "ARouter interceptors init over."); } }); } catch (RuntimeException ex) { diff --git a/arouter-api/src/main/java/com/alibaba/android/arouter/core/Warehouse.java b/arouter-api/src/main/java/com/alibaba/android/arouter/core/Warehouse.java index 380f0fd6b..347832efa 100644 --- a/arouter-api/src/main/java/com/alibaba/android/arouter/core/Warehouse.java +++ b/arouter-api/src/main/java/com/alibaba/android/arouter/core/Warehouse.java @@ -6,9 +6,7 @@ import com.alibaba.android.arouter.facade.template.IProvider; import com.alibaba.android.arouter.facade.template.IRouteGroup; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; /** @@ -29,14 +27,12 @@ class Warehouse { // Cache interceptor static Map> interceptorsIndex = new UniqueKeyTreeMap<>("More than one interceptors use same priority [%s]"); - static List interceptors = new ArrayList<>(); static void clear() { routes.clear(); groupsIndex.clear(); providers.clear(); providersIndex.clear(); - interceptors.clear(); interceptorsIndex.clear(); } } From 3d370b5468c0843d0035da5595fba6f17fcde348 Mon Sep 17 00:00:00 2001 From: Alex Liu Date: Sat, 5 Sep 2026 22:30:41 +0800 Subject: [PATCH 2/2] build(demo): separate demo and runtime minimum SDK levels --- README.md | 3 +++ README_CN.md | 3 +++ app/build.gradle | 2 +- gradle.properties | 2 ++ gradle/TESTING.md | 36 ++++++++++++++++++++++++++++++++++-- module-java/build.gradle | 4 ++-- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 64df7c935..6acf6f394 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ the legacy Support Library should remain on an ARouter 1.x release until the app migrated to AndroidX. The namespace change is a source and binary compatibility boundary, so its release will be handled as a major-version change. +ARouter's runtime keeps `minSdkVersion=14`. The demo application and its Gson-based +`module-java` use `minSdkVersion=21`; Gson is not an ARouter runtime dependency. + 1. Adding dependencies and configurations ``` gradle android { diff --git a/README_CN.md b/README_CN.md index eb6fea78c..5827ca5e7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -49,6 +49,9 @@ AndroidX 迁移后再升级。命名空间变化构成源码和二进制兼容边界,因此正式发布时会按大版本 变更处理。 +ARouter 运行库仍保持 `minSdkVersion=14`。示例应用及其中使用 Gson 的 `module-java` +采用 `minSdkVersion=21`;Gson 不是 ARouter 运行库的依赖。 + 1. 添加依赖和配置 ``` gradle android { diff --git a/app/build.gradle b/app/build.gradle index 2caa48521..a270c3e5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,7 +9,7 @@ android { } defaultConfig { - minSdkVersion Integer.parseInt(MIN_SDK_VERSION) + minSdkVersion Integer.parseInt(DEMO_MIN_SDK_VERSION) targetSdkVersion Integer.parseInt(TARGET_SDK_VERSION) versionName "0.0.1" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' diff --git a/gradle.properties b/gradle.properties index ecf0e93b3..74153bb35 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,6 +27,8 @@ ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.1.3 ANDROIDX_TEST_RUNNER_VERSION=1.3.0 ANDROIDX_TEST_EXT_JUNIT_VERSION=1.1.2 MIN_SDK_VERSION=14 +# The Gson-based demo has a higher minimum than the ARouter runtime. +DEMO_MIN_SDK_VERSION=21 TARGET_SDK_VERSION=28 # ARouter is compiled and tested as a native AndroidX library. Jetifier stays diff --git a/gradle/TESTING.md b/gradle/TESTING.md index 188862c7f..41e85860d 100644 --- a/gradle/TESTING.md +++ b/gradle/TESTING.md @@ -2,8 +2,9 @@ These checks validate development artifacts, not a release or publication. ARouter's published minimum dependencies and `minSdkVersion=14` are unchanged. -Device coverage starts at API 21; it does not establish runtime coverage for -every API level between 14 and 20. +The complete demo CI matrix runs on API 21 and API 34. Minimum-API framework +and consumer checks are separate; they do not establish coverage for every +intermediate API level. ## Complete framework device suite @@ -32,6 +33,37 @@ The verifier requires fresh, nonempty JUnit reports with no failures, errors, or skipped tests. Local copies are retained by API and build type under `build/reports/device-tests/`, so later runs do not erase earlier matrix results. +## Minimum API 14 runtime checks + +Connect exactly one booted API 14 emulator and verify its API level before +running the standalone framework tests with JDK 8: + +```sh +"${ANDROID_HOME}/platform-tools/adb" shell getprop ro.build.version.sdk +./gradlew :arouter-api:connectedDebugAndroidTest +``` + +For end-to-end routing and Release/R8, run the published-artifact consumer +below on the same emulator with the minimum dependencies and `AROUTER_MIN_SDK=14`. +This exercises both daily/online Debug/Release variants without test-only keep +rules. Preserve the fixture and check its fresh JUnit reports, not just APK +assembly. The consumer covers Activity and provider routing, Fragment and +DialogFragment creation, arguments and injection. `withOptionsCompat` requires +API 16 and is deliberately not exercised on API 14. + +The demo's Gson 2.14 dependency requires [API 21 or newer](https://github.com/google/gson#minimum-android-api-level). +The demo application and its Gson-based `module-java` therefore use +`DEMO_MIN_SDK_VERSION=21`, separately from the runtime's `MIN_SDK_VERSION=14`. +Do not use the demo as proof of the runtime library's minimum API. Gson is a +JVM compiler/demo dependency, not an ARouter runtime dependency. + +The official API 14 ARM image uses the old `kernel-qemu`/classic engine. +Emulator 36.1.9 cannot boot it. An isolated SDK Tools 25.2.5 classic engine +was validated on macOS through Rosetta; the [official macOS archive](https://dl.google.com/android/repository/tools_r25.2.5-macosx.zip) +can be extracted separately without replacing a working SDK emulator. +See the [SDK Tools release history](https://developer.android.com/tools/releases/sdk-tools) +and keep the chosen engine version and system-image checksum with test results. + ## Published-artifact consumer The dependency verifier requires `rg` (ripgrep), `unzip`, and `strings` diff --git a/module-java/build.gradle b/module-java/build.gradle index dafb56cb1..c8b30822f 100644 --- a/module-java/build.gradle +++ b/module-java/build.gradle @@ -17,7 +17,7 @@ android { buildToolsVersion BUILDTOOLS_VERSION defaultConfig { - minSdkVersion Integer.parseInt(MIN_SDK_VERSION) + minSdkVersion Integer.parseInt(DEMO_MIN_SDK_VERSION) targetSdkVersion Integer.parseInt(TARGET_SDK_VERSION) javaCompileOptions { @@ -29,7 +29,7 @@ android { compileOptions { // Gson 2.14 contains Java 8 default interface methods. Enable Android's - // desugaring pipeline so this minSdk 14 demo module can package tests. + // desugaring pipeline for the demo; Gson's runtime minimum is API 21. sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 }