diff options
author | Ram Peri <ramperi@google.com> | 2023-05-11 13:40:17 -0400 |
---|---|---|
committer | Kevin Liu <congxiliu@google.com> | 2023-06-20 15:07:24 +0000 |
commit | a99b56cbdb00d3f2673b19d5d729912686a14ef5 (patch) | |
tree | 47e18158a12b86676f2b5e54a1f7b98764a73509 | |
parent | 4ca303b6bf529339bf94801b87c9c6877cc92387 (diff) | |
download | robolectric-a99b56cbdb00d3f2673b19d5d729912686a14ef5.tar.gz |
Merge branch 'upstream-google' into rng_import18
Test: atest -c MyRoboTests
Bug: 281899632
Merged-In: Ife59e70205fa3b8dd3aa4cdef4689a8fd276073c
Merged-In: Icd6f0f022a103c2384c8f01d78867f72646320dd
Change-Id: Ife59e70205fa3b8dd3aa4cdef4689a8fd276073c
(cherry picked from commit 6660b73edd97a19ea275f13329641cc28d474ce1)
72 files changed, 4103 insertions, 241 deletions
diff --git a/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java b/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java index fa11ce7e2..eea5deaee 100644 --- a/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java +++ b/integration_tests/ctesque/src/sharedTest/java/android/database/SQLiteDatabaseTest.java @@ -12,6 +12,7 @@ import android.database.sqlite.SQLiteException; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SdkSuppress; +import androidx.test.filters.Suppress; import com.google.common.base.Ascii; import com.google.common.base.Throwables; import com.google.common.io.ByteStreams; @@ -174,7 +175,8 @@ public class SQLiteDatabaseTest { } // TODO(hoisie): This test crashes in emulators, enable when it is fixed in Android. - @SdkSuppress(minSdkVersion = 34) + // Use Suppress here to stop it from running on emulators, but not on Robolectric + @Suppress @Test public void cursorWindow_finalize_concurrentStressTest() throws Throwable { final PrintStream originalErr = System.err; @@ -223,4 +225,18 @@ public class SQLiteDatabaseTest { c.close(); assertThat(sorted).containsExactly("aaa", "abc", "ABC", "bbb").inOrder(); } + + @Test + @Config(minSdk = LOLLIPOP) + @SdkSuppress(minSdkVersion = LOLLIPOP) + public void regex_selection() { + ContentValues values = new ContentValues(); + values.put("first_column", "test"); + database.insert("table_name", null, values); + String select = "first_column regexp ?"; + String[] selectArgs = { + "test", + }; + assertThat(database.delete("table_name", select, selectArgs)).isEqualTo(1); + } } diff --git a/nativeruntime/build.gradle b/nativeruntime/build.gradle index 1ef93175a..f8d496d5b 100644 --- a/nativeruntime/build.gradle +++ b/nativeruntime/build.gradle @@ -66,7 +66,7 @@ dependencies { api project(":utils:reflector") api "com.google.guava:guava:$guavaJREVersion" - implementation "org.robolectric:nativeruntime-dist-compat:1.0.0" + implementation "org.robolectric:nativeruntime-dist-compat:1.0.1" annotationProcessor "com.google.auto.service:auto-service:$autoServiceVersion" compileOnly "com.google.auto.service:auto-service-annotations:$autoServiceVersion" diff --git a/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java index fd5e77dd2..d9905f6cf 100644 --- a/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java +++ b/processor/src/main/java/org/robolectric/annotation/processing/RobolectricModel.java @@ -1,5 +1,6 @@ package org.robolectric.annotation.processing; +import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.Maps.newHashMap; import static com.google.common.collect.Maps.newTreeMap; import static com.google.common.collect.Sets.newTreeSet; @@ -127,6 +128,10 @@ public class RobolectricModel { } public void addResetter(TypeElement shadowTypeElement, ExecutableElement elem) { + checkState( + !resetterMap.containsKey(shadowTypeElement.getQualifiedName().toString()), + "Trying to register a duplicate resetter on %s", + shadowTypeElement.getQualifiedName()); registerType(shadowTypeElement); resetterMap.put(shadowTypeElement.getQualifiedName().toString(), diff --git a/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java b/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java index d409f8396..39df257f9 100644 --- a/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java +++ b/processor/src/main/java/org/robolectric/annotation/processing/validator/ResetterValidator.java @@ -1,6 +1,9 @@ package org.robolectric.annotation.processing.validator; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.ExecutableElement; @@ -13,6 +16,9 @@ import org.robolectric.annotation.processing.RobolectricModel; * Validator that checks usages of {@link org.robolectric.annotation.Resetter}. */ public class ResetterValidator extends FoundOnImplementsValidator { + + private final Map<TypeElement, ExecutableElement> resetterMethodsByClass = new HashMap<>(); + public ResetterValidator(RobolectricModel.Builder modelBuilder, ProcessingEnvironment env) { super(modelBuilder, env, "org.robolectric.annotation.Resetter"); } @@ -35,7 +41,19 @@ public class ResetterValidator extends FoundOnImplementsValidator { error("@Resetter methods must not have parameters"); error = true; } + if (resetterMethodsByClass.containsKey(parent)) { + error( + String.format( + Locale.US, + "Duplicate @Resetter methods found on %s: %s() and %s(). Only one @Resetter method" + + " is permitted on each shadow.", + parent.getQualifiedName(), + resetterMethodsByClass.get(parent).getSimpleName(), + elem.getSimpleName())); + error = true; + } if (!error) { + resetterMethodsByClass.put(parent, elem); modelBuilder.addResetter(parent, elem); } } diff --git a/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java b/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java index 68900409b..c7924e8b0 100644 --- a/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java +++ b/processor/src/test/java/org/robolectric/annotation/processing/validator/ResetterValidatorTest.java @@ -12,7 +12,8 @@ import org.junit.runners.JUnit4; public class ResetterValidatorTest { @Test public void resetterWithoutImplements_shouldNotCompile() { - final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterWithoutImplements"; + final String testClass = + "org.robolectric.annotation.processing.shadows.ShadowResetterWithoutImplements"; assertAbout(singleClass()) .that(testClass) .failsToCompile() @@ -22,7 +23,8 @@ public class ResetterValidatorTest { @Test public void nonStaticResetter_shouldNotCompile() { - final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterNonStatic"; + final String testClass = + "org.robolectric.annotation.processing.shadows.ShadowResetterNonStatic"; assertAbout(singleClass()) .that(testClass) .failsToCompile() @@ -32,7 +34,8 @@ public class ResetterValidatorTest { @Test public void nonPublicResetter_shouldNotCompile() { - final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterNonPublic"; + final String testClass = + "org.robolectric.annotation.processing.shadows.ShadowResetterNonPublic"; assertAbout(singleClass()) .that(testClass) .failsToCompile() @@ -42,7 +45,8 @@ public class ResetterValidatorTest { @Test public void resetterWithParameters_shouldNotCompile() { - final String testClass = "org.robolectric.annotation.processing.shadows.ShadowResetterWithParameters"; + final String testClass = + "org.robolectric.annotation.processing.shadows.ShadowResetterWithParameters"; assertAbout(singleClass()) .that(testClass) .failsToCompile() @@ -51,6 +55,20 @@ public class ResetterValidatorTest { } @Test + public void twoValidResetters_shouldNotCompile() { + final String testClass = "org.robolectric.annotation.processing.shadows.ShadowWithTwoResetters"; + + assertAbout(singleClass()) + .that(testClass) + .failsToCompile() + .withErrorContaining( + "Duplicate @Resetter methods found on" + + " org.robolectric.annotation.processing.shadows.ShadowWithTwoResetters:" + + " resetter_method_one() and resetter_method_two().") + .onLine(13); + } + + @Test public void goodResetter_shouldCompile() { final String testClass = "org.robolectric.annotation.processing.shadows.ShadowDummy"; assertAbout(singleClass()) diff --git a/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithTwoResetters.java b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithTwoResetters.java new file mode 100644 index 000000000..8183073b6 --- /dev/null +++ b/processor/src/test/resources/org/robolectric/annotation/processing/shadows/ShadowWithTwoResetters.java @@ -0,0 +1,15 @@ +package org.robolectric.annotation.processing.shadows; + +import com.example.objects.Dummy; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Resetter; + +@Implements(Dummy.class) +public class ShadowWithTwoResetters { + + @Resetter + public static void resetter_method_one() {} + + @Resetter + public static void resetter_method_two() {} +} diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java index a6908af09..dd1bc5cca 100644 --- a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java +++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java @@ -157,7 +157,7 @@ public class LocalUiController implements UiController { @Override public void loopMainThreadUntilIdle() { - if (!ShadowLooper.looperMode().equals(LooperMode.Mode.PAUSED)) { + if (ShadowLooper.looperMode().equals(LooperMode.Mode.LEGACY)) { shadowMainLooper().idle(); } else { ImmutableSet<IdlingResourceProxy> idlingResources = syncIdlingResources(); diff --git a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java index 0ba55d893..c0108de97 100644 --- a/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java +++ b/robolectric/src/test/java/org/robolectric/RobolectricTestRunnerTest.java @@ -50,6 +50,7 @@ import org.robolectric.annotation.Config; import org.robolectric.annotation.Config.Implementation; import org.robolectric.annotation.experimental.LazyApplication; import org.robolectric.annotation.experimental.LazyApplication.LazyLoad; +import org.robolectric.config.ConfigurationRegistry; import org.robolectric.internal.AndroidSandbox.TestEnvironmentSpec; import org.robolectric.internal.ResourcesMode; import org.robolectric.internal.ShadowProvider; @@ -163,10 +164,10 @@ public class RobolectricTestRunnerTest { assertThat(events) .containsExactly( "started: first", - "failure: ShadowActivityThread.reset: ActivityThread not set", + "failure: fake error in setUpApplicationState", "finished: first", "started: second", - "failure: ShadowActivityThread.reset: ActivityThread not set", + "failure: fake error in setUpApplicationState", "finished: second") .inOrder(); } @@ -319,6 +320,9 @@ public class RobolectricTestRunnerTest { @Override public void setUpApplicationState(Method method, Configuration configuration, AndroidManifest appManifest) { + // ConfigurationRegistry.instance is required for resetters. + Config config = configuration.get(Config.class); + ConfigurationRegistry.instance = new ConfigurationRegistry(configuration.map()); throw new RuntimeException("fake error in setUpApplicationState"); } } diff --git a/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java index e3163ccdc..679b456f2 100644 --- a/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java +++ b/robolectric/src/test/java/org/robolectric/interceptors/AndroidInterceptorsIntegrationTest.java @@ -66,10 +66,10 @@ public class AndroidInterceptorsIntegrationTest { @Test public void systemNanoTime_shouldReturnShadowClockTime() throws Throwable { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - SystemClock.setCurrentTimeMillis(200); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowSystemClock.setNanoTime(Duration.ofMillis(200).toNanos()); + } else { + SystemClock.setCurrentTimeMillis(200); } long nanoTime = invokeDynamic(System.class, "nanoTime", long.class); @@ -78,10 +78,10 @@ public class AndroidInterceptorsIntegrationTest { @Test public void systemCurrentTimeMillis_shouldReturnShadowClockTime() throws Throwable { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - SystemClock.setCurrentTimeMillis(200); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowSystemClock.setNanoTime(Duration.ofMillis(200).toNanos()); + } else { + SystemClock.setCurrentTimeMillis(200); } long currentTimeMillis = invokeDynamic(System.class, "currentTimeMillis", long.class); diff --git a/robolectric/src/test/java/org/robolectric/shadows/CellIdentityLteBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/CellIdentityLteBuilderTest.java new file mode 100644 index 000000000..72887ddb5 --- /dev/null +++ b/robolectric/src/test/java/org/robolectric/shadows/CellIdentityLteBuilderTest.java @@ -0,0 +1,114 @@ +package org.robolectric.shadows; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Build; +import android.telephony.CellIdentityLte; +import android.telephony.CellInfo; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Test for {@link CellIdentityLteBuilder} */ +@RunWith(AndroidJUnit4.class) +@Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellIdentityLteBuilderTest { + + private static final String MCC = "310"; + private static final String MNC = "260"; + private static final int CI = 0; + private static final int PCI = 1; + private static final int TAC = 2; + private static final int EARFCN = 4; + private static final int[] BANDS = new int[] {2, 4}; + private static final int BANDWIDTH = 5; + private static final String SHORT_OPERATOR_NAME = "short operator name"; + private static final String LONG_OPERATOR_NAME = "long operator name"; + private static final ImmutableList<String> ADDITIONAL_PLMNS = ImmutableList.of("310240"); + + @Test + public void build_noArguments() { + // The intent is to primarily verify that there are no issues setting default values i.e., no + // exceptions thrown or invalid inputs. + CellIdentityLte cellIdentity = CellIdentityLteBuilder.newBuilder().build(); + + assertThat(cellIdentity.getCi()).isEqualTo(CellInfo.UNAVAILABLE); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1, maxSdk = Build.VERSION_CODES.M) + public void build_sdkJtoM() { + CellIdentityLte cellIdentity = getCellIdentityLte(); + + assertCellIdentityFieldsForAllSdks(cellIdentity); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.N, maxSdk = Build.VERSION_CODES.O_MR1) + public void build_sdkNtoO() { + CellIdentityLte cellIdentity = getCellIdentityLte(); + + assertCellIdentityFieldsForAllSdks(cellIdentity); + assertThat(cellIdentity.getEarfcn()).isEqualTo(EARFCN); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.Q, maxSdk = Build.VERSION_CODES.R) + public void build_sdkPtoQ() { + CellIdentityLte cellIdentity = getCellIdentityLte(); + + assertCellIdentityFieldsForAllSdks(cellIdentity); + assertThat(cellIdentity.getMccString()).isEqualTo(MCC); + assertThat(cellIdentity.getMncString()).isEqualTo(MNC); + assertThat(cellIdentity.getEarfcn()).isEqualTo(EARFCN); + assertThat(cellIdentity.getBandwidth()).isEqualTo(BANDWIDTH); + assertThat(cellIdentity.getOperatorAlphaLong().toString()).isEqualTo(LONG_OPERATOR_NAME); + assertThat(cellIdentity.getOperatorAlphaShort().toString()).isEqualTo(SHORT_OPERATOR_NAME); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.S) + public void build_fromSdkS() { + CellIdentityLte cellIdentity = getCellIdentityLte(); + + assertCellIdentityFieldsForAllSdks(cellIdentity); + assertThat(cellIdentity.getMccString()).isEqualTo(MCC); + assertThat(cellIdentity.getMncString()).isEqualTo(MNC); + assertThat(cellIdentity.getEarfcn()).isEqualTo(EARFCN); + assertThat(cellIdentity.getBandwidth()).isEqualTo(BANDWIDTH); + assertThat(cellIdentity.getBands()).isEqualTo(BANDS); + assertThat(cellIdentity.getOperatorAlphaLong().toString()).isEqualTo(LONG_OPERATOR_NAME); + assertThat(cellIdentity.getOperatorAlphaShort().toString()).isEqualTo(SHORT_OPERATOR_NAME); + assertThat(cellIdentity.getAdditionalPlmns()).containsExactlyElementsIn(ADDITIONAL_PLMNS); + } + + /** + * Assertions on {@link android.telephony.CellIdentityLte} values that are common across all + * tested SDKs. + */ + private void assertCellIdentityFieldsForAllSdks(CellIdentityLte cellIdentity) { + assertThat(cellIdentity.getMcc()).isEqualTo(Integer.parseInt(MCC)); + assertThat(cellIdentity.getMnc()).isEqualTo(Integer.parseInt(MNC)); + assertThat(cellIdentity.getCi()).isEqualTo(CI); + assertThat(cellIdentity.getPci()).isEqualTo(PCI); + assertThat(cellIdentity.getTac()).isEqualTo(TAC); + } + + private CellIdentityLte getCellIdentityLte() { + return CellIdentityLteBuilder.newBuilder() + .setMcc(MCC) + .setMnc(MNC) + .setCi(CI) + .setPci(PCI) + .setTac(TAC) + .setEarfcn(EARFCN) + .setBands(BANDS) + .setBandwidth(BANDWIDTH) + .setLongOperatorName(LONG_OPERATOR_NAME) + .setShortOperatorName(SHORT_OPERATOR_NAME) + .setAdditionalPlmns(ADDITIONAL_PLMNS) + .build(); + } +} diff --git a/robolectric/src/test/java/org/robolectric/shadows/CellInfoLteBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/CellInfoLteBuilderTest.java new file mode 100644 index 000000000..f61abad90 --- /dev/null +++ b/robolectric/src/test/java/org/robolectric/shadows/CellInfoLteBuilderTest.java @@ -0,0 +1,81 @@ +package org.robolectric.shadows; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Build; +import android.telephony.CellIdentityLte; +import android.telephony.CellInfoLte; +import android.telephony.CellSignalStrengthLte; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.time.Duration; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Test for {@link CellInfoLteBuilder} */ +@RunWith(AndroidJUnit4.class) +@Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellInfoLteBuilderTest { + + private static final boolean REGISTERED = false; + private static final long TIMESTAMP_NANOS = 123L; + private static final long TIMESTAMP_MILLIS = Duration.ofNanos(TIMESTAMP_NANOS).toMillis(); + private static final int CELL_CONNECTION_STATUS = 1; + + private static final CellIdentityLte cellIdentity = + CellIdentityLteBuilder.newBuilder().setMcc("310").build(); + private static final CellSignalStrengthLte cellSignalStrength = + CellSignalStrengthLteBuilder.newBuilder().setRsrp(-120).build(); + + @Test + public void build_noArguments() { + // The intent is to primarily verify that there are no issues setting default values i.e., no + // exceptions thrown or invalid inputs. + CellInfoLte cellInfo = CellInfoLteBuilder.newBuilder().build(); + + assertThat(cellInfo.getTimeStamp()).isEqualTo(0); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1, maxSdk = Build.VERSION_CODES.N_MR1) + public void build_sdkJtoN() { + CellInfoLte cellInfo = getCellInfoLte(); + + assertThat(cellInfo.isRegistered()).isFalse(); + assertThat(cellInfo.getTimeStamp()).isEqualTo(TIMESTAMP_NANOS); + assertThat(cellInfo.getCellSignalStrength()).isEqualTo(cellSignalStrength); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.P, maxSdk = Build.VERSION_CODES.Q) + public void build_fromSdkPtoQ() { + CellInfoLte cellInfo = getCellInfoLte(); + + assertThat(cellInfo.isRegistered()).isFalse(); + assertThat(cellInfo.getTimeStamp()).isEqualTo(TIMESTAMP_NANOS); + assertThat(cellInfo.getCellConnectionStatus()).isEqualTo(CELL_CONNECTION_STATUS); + assertThat(cellInfo.getCellSignalStrength()).isEqualTo(cellSignalStrength); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.R, maxSdk = Config.NEWEST_SDK) + public void build_fromSdkR() { + CellInfoLte cellInfo = getCellInfoLte(); + + assertThat(cellInfo.isRegistered()).isFalse(); + assertThat(cellInfo.getTimestampMillis()).isEqualTo(TIMESTAMP_MILLIS); + assertThat(cellInfo.getCellConnectionStatus()).isEqualTo(CELL_CONNECTION_STATUS); + assertThat(cellInfo.getCellSignalStrength()).isEqualTo(cellSignalStrength); + assertThat(cellInfo.getCellIdentity()).isEqualTo(cellIdentity); + } + + private CellInfoLte getCellInfoLte() { + return CellInfoLteBuilder.newBuilder() + .setRegistered(REGISTERED) + .setTimeStampNanos(TIMESTAMP_NANOS) + .setCellConnectionStatus(CELL_CONNECTION_STATUS) + .setCellIdentity(cellIdentity) + .setCellSignalStrength(cellSignalStrength) + .build(); + } +} diff --git a/robolectric/src/test/java/org/robolectric/shadows/CellSignalStrengthLteBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/CellSignalStrengthLteBuilderTest.java new file mode 100644 index 000000000..cfd3bbeeb --- /dev/null +++ b/robolectric/src/test/java/org/robolectric/shadows/CellSignalStrengthLteBuilderTest.java @@ -0,0 +1,95 @@ +package org.robolectric.shadows; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Build; +import android.telephony.CellInfo; +import android.telephony.CellSignalStrengthLte; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Test for {@link CellSignalStrengthLteBuilder} */ +@RunWith(AndroidJUnit4.class) +@Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellSignalStrengthLteBuilderTest { + + // The platform enforces that some of these values are within a certain range - otherwise, it will + // default to {@link android.telephony.CellInfo.UNAVAILABLE}. + private static final int RSSI = -100; + private static final int RSRP = -120; + private static final int RSRQ = -10; + private static final int RSSNR = 30; + private static final int CQI_TABLE_INDEX = 4; + private static final int CQI = 5; + private static final int TIMING_ADVANCE = 6; + + @Test + public void build_noArguments() { + // The intent is to primarily verify that there are no issues setting default values i.e., no + // exceptions thrown or invalid inputs. + CellSignalStrengthLte cellSignalStrength = CellSignalStrengthLteBuilder.newBuilder().build(); + + assertThat(cellSignalStrength.getTimingAdvance()).isEqualTo(CellInfo.UNAVAILABLE); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.JELLY_BEAN_MR1, maxSdk = Build.VERSION_CODES.N_MR1) + public void build_sdkJtoN() { + CellSignalStrengthLte cellSignalStrength = getCellSignalStrength(); + + assertThat(cellSignalStrength.getTimingAdvance()).isEqualTo(TIMING_ADVANCE); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.O, maxSdk = Build.VERSION_CODES.P) + public void build_sdkOToP() { + CellSignalStrengthLte cellSignalStrength = getCellSignalStrength(); + + assertThat(cellSignalStrength.getRsrp()).isEqualTo(RSRP); + assertThat(cellSignalStrength.getRssnr()).isEqualTo(RSSNR); + assertThat(cellSignalStrength.getRsrq()).isEqualTo(RSRQ); + assertThat(cellSignalStrength.getCqi()).isEqualTo(CQI); + assertThat(cellSignalStrength.getTimingAdvance()).isEqualTo(TIMING_ADVANCE); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.Q, maxSdk = Build.VERSION_CODES.R) + public void build_sdkQtoR() { + CellSignalStrengthLte cellSignalStrength = getCellSignalStrength(); + + assertThat(cellSignalStrength.getRssi()).isEqualTo(RSSI); + assertThat(cellSignalStrength.getRsrp()).isEqualTo(RSRP); + assertThat(cellSignalStrength.getRsrq()).isEqualTo(RSRQ); + assertThat(cellSignalStrength.getRssnr()).isEqualTo(RSSNR); + assertThat(cellSignalStrength.getCqi()).isEqualTo(CQI); + assertThat(cellSignalStrength.getTimingAdvance()).isEqualTo(TIMING_ADVANCE); + } + + @Test + @Config(minSdk = Build.VERSION_CODES.S) + public void build_fromSdkS() { + CellSignalStrengthLte cellSignalStrength = getCellSignalStrength(); + + assertThat(cellSignalStrength.getRssi()).isEqualTo(RSSI); + assertThat(cellSignalStrength.getRsrp()).isEqualTo(RSRP); + assertThat(cellSignalStrength.getRsrq()).isEqualTo(RSRQ); + assertThat(cellSignalStrength.getRssnr()).isEqualTo(RSSNR); + assertThat(cellSignalStrength.getCqiTableIndex()).isEqualTo(CQI_TABLE_INDEX); + assertThat(cellSignalStrength.getCqi()).isEqualTo(CQI); + assertThat(cellSignalStrength.getTimingAdvance()).isEqualTo(TIMING_ADVANCE); + } + + private CellSignalStrengthLte getCellSignalStrength() { + return CellSignalStrengthLteBuilder.newBuilder() + .setRssi(RSSI) + .setRsrp(RSRP) + .setRsrq(RSRQ) + .setRssnr(RSSNR) + .setCqi(CQI) + .setCqiTableIndex(CQI_TABLE_INDEX) + .setTimingAdvance(TIMING_ADVANCE) + .build(); + } +} diff --git a/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java b/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java index 27e635c8f..06769c66e 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/MediaCodecInfoBuilderTest.java @@ -14,6 +14,7 @@ import android.media.MediaCodecInfo.CodecCapabilities; import android.media.MediaCodecInfo.CodecProfileLevel; import android.media.MediaCodecList; import android.media.MediaFormat; +import android.util.Range; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -29,6 +30,10 @@ public class MediaCodecInfoBuilderTest { private static final String VP9_DECODER_NAME = "test.decoder.vp9"; private static final String MULTIFORMAT_ENCODER_NAME = "test.encoder.multiformat"; + private static final int WIDTH = 1920; + private static final int HEIGHT = 1080; + private static final Range<Integer> DEFAULT_SUPPORTED_VIDEO_SIZE_RANGE = new Range<>(2, 896); + private static final MediaFormat AAC_MEDIA_FORMAT = createMediaFormat( MIMETYPE_AUDIO_AAC, new String[] {CodecCapabilities.FEATURE_DynamicTimestamp}); @@ -37,6 +42,9 @@ public class MediaCodecInfoBuilderTest { MIMETYPE_AUDIO_OPUS, new String[] {CodecCapabilities.FEATURE_AdaptivePlayback}); private static final MediaFormat AVC_MEDIA_FORMAT = createMediaFormat(MIMETYPE_VIDEO_AVC, new String[] {CodecCapabilities.FEATURE_IntraRefresh}); + private static final MediaFormat AVC_MEDIA_FORMAT_WITH_RESOLUTION = + createMediaFormat( + MIMETYPE_VIDEO_AVC, WIDTH, HEIGHT, new String[] {CodecCapabilities.FEATURE_IntraRefresh}); private static final MediaFormat VP9_MEDIA_FORMAT = createMediaFormat( MIMETYPE_VIDEO_VP9, @@ -123,6 +131,10 @@ public class MediaCodecInfoBuilderTest { assertThat(codecCapabilities.getMimeType()).isEqualTo(MIMETYPE_VIDEO_AVC); assertThat(codecCapabilities.getAudioCapabilities()).isNull(); assertThat(codecCapabilities.getVideoCapabilities()).isNotNull(); + assertThat(codecCapabilities.getVideoCapabilities().getSupportedWidths()) + .isEqualTo(DEFAULT_SUPPORTED_VIDEO_SIZE_RANGE); + assertThat(codecCapabilities.getVideoCapabilities().getSupportedHeights()) + .isEqualTo(DEFAULT_SUPPORTED_VIDEO_SIZE_RANGE); assertThat(codecCapabilities.getEncoderCapabilities()).isNotNull(); assertThat(codecCapabilities.isFeatureSupported(CodecCapabilities.FEATURE_IntraRefresh)) .isTrue(); @@ -136,6 +148,24 @@ public class MediaCodecInfoBuilderTest { @Test @Config(minSdk = Q) + public void canCreateVideoEncoderCapabilities_supportedFormatResolutionIsSet() { + CodecCapabilities codecCapabilities = + MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder() + .setMediaFormat(AVC_MEDIA_FORMAT_WITH_RESOLUTION) + .setIsEncoder(true) + .setProfileLevels(AVC_PROFILE_LEVELS) + .setColorFormats(AVC_COLOR_FORMATS) + .build(); + + assertThat(codecCapabilities.getVideoCapabilities()).isNotNull(); + assertThat(codecCapabilities.getVideoCapabilities().getSupportedWidths()) + .isEqualTo(new Range<>(1, WIDTH)); + assertThat(codecCapabilities.getVideoCapabilities().getSupportedHeights()) + .isEqualTo(new Range<>(1, HEIGHT)); + } + + @Test + @Config(minSdk = Q) public void canCreateVideoDecoderCapabilities() { CodecCapabilities codecCapabilities = MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder() @@ -353,4 +383,24 @@ public class MediaCodecInfoBuilderTest { } return mediaFormat; } + + /** + * Create a sample {@link MediaFormat}. + * + * @param mime one of MIMETYPE_* from {@link MediaFormat}. + * @param width The width of the content (in pixels). + * @param height The height of the content (in pixels). + * @param features an array of CodecCapabilities.FEATURE_ features to be enabled. + */ + private static MediaFormat createMediaFormat( + String mime, int width, int height, String[] features) { + MediaFormat mediaFormat = new MediaFormat(); + mediaFormat.setString(MediaFormat.KEY_MIME, mime); + mediaFormat.setInteger(MediaFormat.KEY_WIDTH, width); + mediaFormat.setInteger(MediaFormat.KEY_HEIGHT, height); + for (String feature : features) { + mediaFormat.setFeatureEnabled(feature, true); + } + return mediaFormat; + } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java index b798a74a3..d5c0c46ca 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioManagerTest.java @@ -8,6 +8,7 @@ import static android.os.Build.VERSION_CODES.P; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth8.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -24,6 +25,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioPlaybackConfiguration; import android.media.AudioRecordingConfiguration; +import android.media.AudioSystem; import android.media.MediaRecorder.AudioSource; import android.media.audiopolicy.AudioPolicy; import androidx.test.core.app.ApplicationProvider; @@ -991,6 +993,190 @@ public class ShadowAudioManagerTest { assertThat(audioSessionId).isNotEqualTo(audioSessionId2); } + @Test + @Config(minSdk = Q) + public void isOffloadSupported_withoutSupport() { + assertThat( + AudioManager.isOffloadedPlaybackSupported( + new AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_AC3).build(), + new AudioAttributes.Builder().build())) + .isFalse(); + } + + @Test + @Config(minSdk = Q, maxSdk = R) + public void isOffloadSupported_withSetOffloadSupported() { + AudioFormat format = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isFalse(); + + ShadowAudioSystem.setOffloadSupported(format, attributes, true); + + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isTrue(); + } + + @Test + @Config(minSdk = Q, maxSdk = R) + public void isOffloadSupported_withSetOffloadSupportedAddedAndRemoved() { + AudioFormat format = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + ShadowAudioSystem.setOffloadSupported(format, attributes, true); + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isTrue(); + + ShadowAudioSystem.setOffloadSupported(format, attributes, false); + + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isFalse(); + } + + @Test + @Config(minSdk = S) + public void isOffloadSupported_withSetOffloadPlaybackSupport() { + AudioFormat format = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isFalse(); + + ShadowAudioSystem.setOffloadPlaybackSupport(format, attributes, AudioSystem.OFFLOAD_SUPPORTED); + + assertThat(AudioManager.isOffloadedPlaybackSupported(format, attributes)).isTrue(); + } + + @Test + @Config(minSdk = S) + public void getPlaybackOffloadSupport_withSetOffloadSupport_returnsOffloadSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_AAC_HE_V2) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(); + ShadowAudioSystem.setOffloadPlaybackSupport( + audioFormat, audioAttributes, AudioSystem.OFFLOAD_SUPPORTED); + + int playbackOffloadSupport = + AudioManager.getPlaybackOffloadSupport(audioFormat, audioAttributes); + + assertThat(playbackOffloadSupport).isEqualTo(AudioSystem.OFFLOAD_SUPPORTED); + } + + @Test + @Config(minSdk = S) + public void + getPlaybackOffloadSupport_withoutSetDirectPlaybackSupport_returnsOffloadNotSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_AAC_HE_V2) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build(); + + int playbackOffloadSupport = + AudioManager.getPlaybackOffloadSupport(audioFormat, audioAttributes); + + assertThat(playbackOffloadSupport).isEqualTo(AudioSystem.OFFLOAD_NOT_SUPPORTED); + } + + @Test + @Config(minSdk = S) + public void getPlaybackOffloadSupport_withSameAudioAttrUsage_returnsOffloadSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_AAC_HE_V2) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(); + ShadowAudioSystem.setOffloadPlaybackSupport( + audioFormat, audioAttributes, AudioSystem.OFFLOAD_SUPPORTED); + + AudioAttributes audioAttributes2 = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(); + int playbackOffloadSupport = + AudioManager.getPlaybackOffloadSupport(audioFormat, audioAttributes2); + + assertThat(playbackOffloadSupport).isEqualTo(AudioSystem.OFFLOAD_SUPPORTED); + } + + @Test + @Config(minSdk = TIRAMISU) + public void getDirectPlaybackSupport_withSetDirectPlaybackSupport_returnsOffloadSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_AAC_HE_V2) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(); + ShadowAudioSystem.setDirectPlaybackSupport( + audioFormat, audioAttributes, AudioSystem.DIRECT_OFFLOAD_SUPPORTED); + + int playbackOffloadSupport = + AudioManager.getDirectPlaybackSupport(audioFormat, audioAttributes); + + assertThat(playbackOffloadSupport).isEqualTo(AudioSystem.DIRECT_OFFLOAD_SUPPORTED); + } + + @Test + @Config(minSdk = TIRAMISU) + public void getDirectPlaybackSupport_withShadowAudioSystemReset_returnsOffloadNotSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_AAC_HE_V2) + .build(); + AudioAttributes audioAttributes = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build(); + ShadowAudioSystem.setDirectPlaybackSupport( + audioFormat, audioAttributes, AudioSystem.DIRECT_OFFLOAD_SUPPORTED); + ShadowAudioSystem.reset(); + + int playbackOffloadSupport = + AudioManager.getDirectPlaybackSupport(audioFormat, audioAttributes); + + assertThat(playbackOffloadSupport).isEqualTo(AudioSystem.DIRECT_NOT_SUPPORTED); + } + private static AudioDeviceInfo createAudioDevice(int type) throws ReflectiveOperationException { AudioDeviceInfo info = Shadow.newInstanceOf(AudioDeviceInfo.class); Field portField = AudioDeviceInfo.class.getDeclaredField("mPort"); diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java index adffa0111..e8869bd49 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowAudioTrackTest.java @@ -4,13 +4,20 @@ import static android.media.AudioTrack.ERROR_BAD_VALUE; import static android.media.AudioTrack.WRITE_BLOCKING; import static android.media.AudioTrack.WRITE_NON_BLOCKING; import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.Q; +import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import android.media.AudioAttributes; import android.media.AudioFormat; import android.media.AudioManager; +import android.media.AudioSystem; import android.media.AudioTrack; +import android.media.PlaybackParams; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.nio.ByteBuffer; import org.junit.Test; @@ -172,6 +179,360 @@ public class ShadowAudioTrackTest implements ShadowAudioTrack.OnAudioDataWritten assertThat(written).isEqualTo(ERROR_BAD_VALUE); } + @Test + @Config(minSdk = M) + public void getPlaybackParams_withSetPlaybackParams_returnsSetPlaybackParams() { + PlaybackParams playbackParams = + new PlaybackParams() + .allowDefaults() + .setSpeed(1.0f) + .setPitch(1.0f) + .setAudioFallbackMode(PlaybackParams.AUDIO_FALLBACK_MODE_FAIL); + AudioTrack audioTrack = getSampleAudioTrack(); + audioTrack.setPlaybackParams(playbackParams); + + assertThat(audioTrack.getPlaybackParams()).isEqualTo(playbackParams); + } + + @Test + public void addDirectPlaybackSupport_forPcmEncoding_throws() { + AudioAttributes attributes = new AudioAttributes.Builder().build(); + assertThrows( + IllegalArgumentException.class, + () -> + ShadowAudioTrack.addDirectPlaybackSupport( + getAudioFormat(AudioFormat.ENCODING_PCM_8BIT), attributes)); + assertThrows( + IllegalArgumentException.class, + () -> + ShadowAudioTrack.addDirectPlaybackSupport( + getAudioFormat(AudioFormat.ENCODING_PCM_16BIT), attributes)); + assertThrows( + IllegalArgumentException.class, + () -> + ShadowAudioTrack.addDirectPlaybackSupport( + getAudioFormat(AudioFormat.ENCODING_PCM_24BIT_PACKED), attributes)); + assertThrows( + IllegalArgumentException.class, + () -> + ShadowAudioTrack.addDirectPlaybackSupport( + getAudioFormat(AudioFormat.ENCODING_PCM_32BIT), attributes)); + assertThrows( + IllegalArgumentException.class, + () -> + ShadowAudioTrack.addDirectPlaybackSupport( + getAudioFormat(AudioFormat.ENCODING_PCM_FLOAT), attributes)); + } + + @Test + @Config(minSdk = Q) + public void isDirectPlaybackSupported() { + AudioFormat ac3Format = getAudioFormat(AudioFormat.ENCODING_AC3); + AudioAttributes audioAttributes = new AudioAttributes.Builder().build(); + + assertThat(AudioTrack.isDirectPlaybackSupported(ac3Format, audioAttributes)).isFalse(); + + ShadowAudioTrack.addDirectPlaybackSupport(ac3Format, audioAttributes); + + assertThat(AudioTrack.isDirectPlaybackSupported(ac3Format, audioAttributes)).isTrue(); + } + + @Test + @Config(minSdk = Q) + public void isDirectPlaybackSupported_differentFormatOrAttributeFields() { + AudioFormat ac3Format = new AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_AC3).build(); + AudioAttributes audioAttributes = new AudioAttributes.Builder().build(); + + ShadowAudioTrack.addDirectPlaybackSupport(ac3Format, audioAttributes); + + assertThat( + AudioTrack.isDirectPlaybackSupported( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(65000) + .build(), + audioAttributes)) + .isFalse(); + assertThat( + AudioTrack.isDirectPlaybackSupported( + ac3Format, + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build())) + .isFalse(); + } + + @Test + @Config(minSdk = Q) + public void clearDirectPlaybackSupportedEncodings() { + AudioFormat ac3Format = new AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_AC3).build(); + AudioAttributes audioAttributes = new AudioAttributes.Builder().build(); + ShadowAudioTrack.addDirectPlaybackSupport(ac3Format, audioAttributes); + assertThat(AudioTrack.isDirectPlaybackSupported(ac3Format, audioAttributes)).isTrue(); + + ShadowAudioTrack.clearDirectPlaybackSupportedFormats(); + + assertThat(AudioTrack.isDirectPlaybackSupported(ac3Format, audioAttributes)).isFalse(); + } + + @Test + public void addAllowedNonPcmEncoding_forPcmEncoding_throws() { + assertThrows( + IllegalArgumentException.class, + () -> ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_PCM_8BIT)); + assertThrows( + IllegalArgumentException.class, + () -> ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_PCM_16BIT)); + assertThrows( + IllegalArgumentException.class, + () -> ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_PCM_24BIT_PACKED)); + assertThrows( + IllegalArgumentException.class, + () -> ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_PCM_32BIT)); + assertThrows( + IllegalArgumentException.class, + () -> ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_PCM_FLOAT)); + } + + @Test + @Config(minSdk = Q) + public void createInstance_withNonPcmEncodingNotAllowed_throws() { + assertThrows( + UnsupportedOperationException.class, + () -> + new AudioTrack.Builder() + .setAudioFormat( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build()) + .setBufferSizeInBytes(65536) + .build()); + } + + @Test + @Config(minSdk = Q) + public void createInstance_withNonPcmEncodingAllowed() { + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_AC3); + + new AudioTrack.Builder() + .setAudioFormat( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build()) + .setBufferSizeInBytes(65536) + .build(); + } + + @Test + @Config(minSdk = Q) + public void createInstance_withOffloadAndEncodingNotOffloaded_throws() { + assertThrows( + UnsupportedOperationException.class, + () -> + new AudioTrack.Builder() + .setAudioFormat( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build()) + .setBufferSizeInBytes(65536) + .setOffloadedPlayback(true) + .build()); + } + + @Test + @Config(minSdk = Q, maxSdk = R) + public void createInstance_withOffloadAndEncodingIsOffloadSupported() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + ShadowAudioSystem.setOffloadSupported(audioFormat, attributes, /* supported= */ true); + + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat(audioFormat) + .setAudioAttributes(attributes) + .setBufferSizeInBytes(65536) + .setOffloadedPlayback(true) + .build(); + + assertThat(audioTrack.isOffloadedPlayback()).isTrue(); + } + + @Test + @Config(sdk = S) + public void createInstance_withOffloadAndGetOffloadSupport() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + ShadowAudioSystem.setOffloadPlaybackSupport( + audioFormat, attributes, AudioSystem.OFFLOAD_SUPPORTED); + + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat(audioFormat) + .setAudioAttributes(attributes) + .setBufferSizeInBytes(65536) + .setOffloadedPlayback(true) + .build(); + + assertThat(audioTrack.isOffloadedPlayback()).isTrue(); + } + + @Test + @Config(minSdk = TIRAMISU) + public void createInstance_withOffloadAndGetDirectPlaybackSupport() { + AudioFormat audioFormat = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + ShadowAudioSystem.setDirectPlaybackSupport( + audioFormat, attributes, AudioSystem.OFFLOAD_SUPPORTED); + + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat(audioFormat) + .setAudioAttributes(attributes) + .setBufferSizeInBytes(65536) + .setOffloadedPlayback(true) + .build(); + + assertThat(audioTrack.isOffloadedPlayback()).isTrue(); + } + + @Test + @Config(minSdk = Q) + public void clearAllowedNonPcmEncodings() { + AudioFormat surroundAudioFormat = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_AC3); + new AudioTrack.Builder() + .setAudioFormat(surroundAudioFormat) + .setBufferSizeInBytes(65536) + .build(); + + ShadowAudioTrack.clearAllowedNonPcmEncodings(); + + assertThrows( + UnsupportedOperationException.class, + () -> + new AudioTrack.Builder() + .setAudioFormat(surroundAudioFormat) + .setBufferSizeInBytes(65536) + .build()); + } + + @Test + @Config(minSdk = Q) + public void write_withNonPcmEncodingSupported_succeeds() { + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_AC3); + + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build()) + .setAudioAttributes(new AudioAttributes.Builder().build()) + .setBufferSizeInBytes(32 * 1024) + .build(); + + assertThat(audioTrack.write(new byte[128], 0, 128)).isEqualTo(128); + assertThat(audioTrack.write(new byte[128], 0, 128, AudioTrack.WRITE_BLOCKING)).isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocate(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocate(128), 128, AudioTrack.WRITE_BLOCKING, 0L)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING, 0L)) + .isEqualTo(128); + } + + @Test + @Config(minSdk = Q, maxSdk = R) + public void write_withOffloadUntilApi30_succeeds() { + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_AC3); + AudioFormat ac3Format = + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build(); + AudioAttributes attributes = new AudioAttributes.Builder().build(); + ShadowAudioSystem.setOffloadSupported(ac3Format, attributes, /* supported= */ true); + + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat(ac3Format) + .setAudioAttributes(new AudioAttributes.Builder().build()) + .setBufferSizeInBytes(32 * 1024) + .setOffloadedPlayback(true) + .build(); + + assertThat(audioTrack.write(new byte[128], 0, 128)).isEqualTo(128); + assertThat(audioTrack.write(new byte[128], 0, 128, AudioTrack.WRITE_BLOCKING)).isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocate(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocate(128), 128, AudioTrack.WRITE_BLOCKING, 0L)) + .isEqualTo(128); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING, 0L)) + .isEqualTo(128); + } + + @Test + @Config(minSdk = Q) + public void write_withNonPcmEncodingNoLongerSupported_returnsErrorDeadObject() { + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_AC3); + AudioTrack audioTrack = + new AudioTrack.Builder() + .setAudioFormat( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_AC3) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .build()) + .setAudioAttributes(new AudioAttributes.Builder().build()) + .setBufferSizeInBytes(32 * 1024) + .build(); + + ShadowAudioTrack.clearAllowedNonPcmEncodings(); + + assertThat(audioTrack.write(new byte[128], 0, 128)).isEqualTo(AudioTrack.ERROR_DEAD_OBJECT); + assertThat(audioTrack.write(new byte[128], 0, 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(AudioTrack.ERROR_DEAD_OBJECT); + assertThat(audioTrack.write(ByteBuffer.allocate(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(AudioTrack.ERROR_DEAD_OBJECT); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING)) + .isEqualTo(AudioTrack.ERROR_DEAD_OBJECT); + assertThat(audioTrack.write(ByteBuffer.allocateDirect(128), 128, AudioTrack.WRITE_BLOCKING, 0L)) + .isEqualTo(AudioTrack.ERROR_DEAD_OBJECT); + } + @Override @Config(minSdk = Q) public void onAudioDataWritten( @@ -195,4 +556,8 @@ public class ShadowAudioTrackTest implements ShadowAudioTrack.OnAudioDataWritten .build()) .build(); } + + private AudioFormat getAudioFormat(int encoding) { + return new AudioFormat.Builder().setEncoding(encoding).build(); + } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java index caed17812..78b9edbd9 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothGattTest.java @@ -31,6 +31,7 @@ public class ShadowBluetoothGattTest { private static final String ACTION_DISCOVER = "DISCOVER"; private static final String ACTION_READ = "READ"; private static final String ACTION_WRITE = "WRITE"; + private static final String REMOTE_ADDRESS = "R-A"; private int resultStatus = INITIAL_VALUE; private int resultState = INITIAL_VALUE; @@ -274,6 +275,15 @@ public class ShadowBluetoothGattTest { @Test @Config(minSdk = O) + public void getService_afterAddService() { + shadowOf(bluetoothGatt).addDiscoverableService(service1); + assertThat(bluetoothGatt.discoverServices()).isFalse(); + assertThat(bluetoothGatt.getService(service1.getUuid())).isEqualTo(service1); + assertThat(bluetoothGatt.getService(service2.getUuid())).isNull(); + } + + @Test + @Config(minSdk = O) public void discoverServices_clearsService() { shadowOf(bluetoothGatt).setGattCallback(callback); shadowOf(bluetoothGatt).addDiscoverableService(service1); @@ -471,4 +481,103 @@ public class ShadowBluetoothGattTest { assertThat(resultCharacteristic).isEqualTo(characteristic); assertThat(shadowOf(bluetoothGatt).getLatestWrittenBytes()).isEqualTo(CHARACTERISTIC_VALUE); } + + @Test + public void test_getBluetoothConnectionManager() { + assertThat(shadowOf(bluetoothGatt).getBluetoothConnectionManager()).isNotNull(); + } + + @Test + public void test_notifyConnection_connects() { + shadowOf(bluetoothGatt).notifyConnection(REMOTE_ADDRESS); + assertThat(shadowOf(bluetoothGatt).isConnected()).isTrue(); + assertThat( + shadowOf(bluetoothGatt) + .getBluetoothConnectionManager() + .hasGattClientConnection(REMOTE_ADDRESS)) + .isTrue(); + assertThat(resultStatus).isEqualTo(INITIAL_VALUE); + assertThat(resultState).isEqualTo(INITIAL_VALUE); + assertThat(resultAction).isNull(); + } + + @Test + public void test_notifyConnection_connectsWithCallbackSet() { + shadowOf(bluetoothGatt).setGattCallback(callback); + shadowOf(bluetoothGatt).notifyConnection(REMOTE_ADDRESS); + assertThat(shadowOf(bluetoothGatt).isConnected()).isTrue(); + assertThat( + shadowOf(bluetoothGatt) + .getBluetoothConnectionManager() + .hasGattClientConnection(REMOTE_ADDRESS)) + .isTrue(); + assertThat(resultStatus).isEqualTo(BluetoothGatt.GATT_SUCCESS); + assertThat(resultState).isEqualTo(BluetoothProfile.STATE_CONNECTED); + assertThat(resultAction).isEqualTo(ACTION_CONNECTION); + } + + @Test + public void test_notifyDisconnection_disconnects() { + shadowOf(bluetoothGatt).notifyDisconnection(REMOTE_ADDRESS); + assertThat(shadowOf(bluetoothGatt).isConnected()).isFalse(); + assertThat( + shadowOf(bluetoothGatt) + .getBluetoothConnectionManager() + .hasGattClientConnection(REMOTE_ADDRESS)) + .isFalse(); + assertThat(resultStatus).isEqualTo(INITIAL_VALUE); + assertThat(resultState).isEqualTo(INITIAL_VALUE); + assertThat(resultAction).isNull(); + } + + @Test + public void test_notifyDisconnection_disconnectsWithCallbackSet() { + shadowOf(bluetoothGatt).setGattCallback(callback); + shadowOf(bluetoothGatt).notifyDisconnection(REMOTE_ADDRESS); + assertThat(shadowOf(bluetoothGatt).isConnected()).isFalse(); + assertThat( + shadowOf(bluetoothGatt) + .getBluetoothConnectionManager() + .hasGattClientConnection(REMOTE_ADDRESS)) + .isFalse(); + assertThat(resultStatus).isEqualTo(INITIAL_VALUE); + assertThat(resultState).isEqualTo(INITIAL_VALUE); + assertThat(resultAction).isNull(); + } + + @Test + public void test_notifyDisconnection_disconnectsWithCallbackSet_connectedInitially() { + shadowOf(bluetoothGatt).setGattCallback(callback); + shadowOf(bluetoothGatt).notifyConnection(REMOTE_ADDRESS); + shadowOf(bluetoothGatt).notifyDisconnection(REMOTE_ADDRESS); + assertThat( + shadowOf(bluetoothGatt) + .getBluetoothConnectionManager() + .hasGattClientConnection(REMOTE_ADDRESS)) + .isFalse(); + assertThat(shadowOf(bluetoothGatt).isConnected()).isFalse(); + assertThat(resultStatus).isEqualTo(BluetoothGatt.GATT_SUCCESS); + assertThat(resultState).isEqualTo(BluetoothProfile.STATE_DISCONNECTED); + assertThat(resultAction).isEqualTo(ACTION_CONNECTION); + } + + @Test + @Config(minSdk = O) + public void allowCharacteristicNotification_canSetNotification() { + service1.addCharacteristic(characteristicWithReadProperty); + shadowOf(bluetoothGatt).addDiscoverableService(service1); + shadowOf(bluetoothGatt).allowCharacteristicNotification(characteristicWithReadProperty); + assertThat(bluetoothGatt.setCharacteristicNotification(characteristicWithReadProperty, true)) + .isTrue(); + } + + @Test + @Config(minSdk = O) + public void disallowCharacteristicNotification_cannotSetNotification() { + service1.addCharacteristic(characteristicWithReadProperty); + shadowOf(bluetoothGatt).addDiscoverableService(service1); + shadowOf(bluetoothGatt).disallowCharacteristicNotification(characteristicWithReadProperty); + assertThat(bluetoothGatt.setCharacteristicNotification(characteristicWithReadProperty, true)) + .isFalse(); + } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java index 9482ba8d7..cae11d9fa 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBluetoothHeadsetTest.java @@ -22,9 +22,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; import java.util.List; import org.junit.Before; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; @@ -37,8 +35,6 @@ public class ShadowBluetoothHeadsetTest { private BluetoothHeadset bluetoothHeadset; private Application context; - @Rule public ExpectedException thrown = ExpectedException.none(); - @Before public void setUp() throws Exception { device1 = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:11:22:33:AA:BB"); @@ -61,6 +57,41 @@ public class ShadowBluetoothHeadsetTest { } @Test + public void getConnectedDevices_doesNotReturnDevicesInNonConnectedStates() { + shadowOf(bluetoothHeadset).addDevice(device1, BluetoothProfile.STATE_CONNECTING); + shadowOf(bluetoothHeadset).addDevice(device2, BluetoothProfile.STATE_DISCONNECTING); + + assertThat(bluetoothHeadset.getConnectedDevices()).isEmpty(); + } + + @Test + public void getConnectionState_returnsStoredConnectionState() { + shadowOf(bluetoothHeadset).addDevice(device1, BluetoothProfile.STATE_CONNECTING); + shadowOf(bluetoothHeadset).addDevice(device2, BluetoothProfile.STATE_DISCONNECTING); + + assertThat(bluetoothHeadset.getConnectionState(device1)) + .isEqualTo(BluetoothProfile.STATE_CONNECTING); + assertThat(bluetoothHeadset.getConnectionState(device2)) + .isEqualTo(BluetoothProfile.STATE_DISCONNECTING); + } + + @Test + public void removeDevice_getConnectionStateReturnsDisconnected() { + shadowOf(bluetoothHeadset).addConnectedDevice(device1); + shadowOf(bluetoothHeadset).removeDevice(device1); + + assertThat(bluetoothHeadset.getConnectedDevices()).isEmpty(); + } + + @Test + public void removeDevice_getConnectedDevicesReturnsEmpty() { + shadowOf(bluetoothHeadset).addConnectedDevice(device1); + shadowOf(bluetoothHeadset).removeDevice(device1); + + assertThat(bluetoothHeadset.getConnectedDevices()).isEmpty(); + } + + @Test public void getConnectionState_defaultsToDisconnected() { shadowOf(bluetoothHeadset).addConnectedDevice(device1); shadowOf(bluetoothHeadset).addConnectedDevice(device2); diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java index 8ee669ae1..8000cc656 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowInputManagerTest.java @@ -1,10 +1,14 @@ package org.robolectric.shadows; import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static com.google.common.truth.Truth.assertThat; import android.content.Context; import android.hardware.input.InputManager; +import android.hardware.input.InputManager.InputDeviceListener; +import android.os.Handler; +import android.os.Looper; import android.view.MotionEvent; import android.view.VerifiedMotionEvent; import androidx.test.core.app.ApplicationProvider; @@ -16,7 +20,7 @@ import org.robolectric.annotation.Config; /** Unit tests for {@link ShadowInputManager}. */ @RunWith(AndroidJUnit4.class) -@Config(minSdk = R) +@Config(minSdk = R, maxSdk = TIRAMISU) public class ShadowInputManagerTest { private InputManager inputManager; @@ -38,4 +42,22 @@ public class ShadowInputManagerTest { assertThat(verifiedMotionEvent.getEventTimeNanos()).isEqualTo(23456000000L); assertThat(verifiedMotionEvent.getDownTimeNanos()).isEqualTo(12345000000L); } + + static class InputDeviceListenerNoOp implements InputDeviceListener { + @Override + public void onInputDeviceAdded(int deviceId) {} + + @Override + public void onInputDeviceRemoved(int deviceId) {} + + @Override + public void onInputDeviceChanged(int deviceId) {} + } + + @Test + public void testRegisterInputDeviceListener_doesNotCrash() { + InputDeviceListenerNoOp listener = new InputDeviceListenerNoOp(); + inputManager.registerInputDeviceListener(listener, new Handler(Looper.getMainLooper())); + inputManager.unregisterInputDeviceListener(listener); + } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java index cadc3e969..8801f1e1a 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java @@ -4,6 +4,7 @@ import static android.os.Looper.getMainLooper; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.Executors.newSingleThreadExecutor; import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; @@ -526,10 +527,8 @@ public class ShadowPausedLooperTest { } @Test - public void testIdleNotStuck_whenThreadCrashes() throws Exception { - HandlerThread thread = new HandlerThread("WillCrash"); - thread.start(); - Looper looper = thread.getLooper(); + public void idle_looperPaused_idleHandlerThrowsException() throws Exception { + Looper looper = handlerThread.getLooper(); shadowOf(looper).pause(); new Handler(looper) .post( @@ -537,12 +536,69 @@ public class ShadowPausedLooperTest { Looper.myQueue() .addIdleHandler( () -> { - throw new RuntimeException(); + throw new IllegalStateException(); }); }); - shadowOf(looper).idle(); - thread.join(5_000); - assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED); + assertThrows(IllegalStateException.class, () -> shadowOf(looper).idle()); + handlerThread.join(5_000); + assertThat(handlerThread.getState()).isEqualTo(Thread.State.TERMINATED); + } + + @Test + public void idle_looperPaused_runnableThrowsException() throws Exception { + Looper looper = handlerThread.getLooper(); + shadowOf(looper).pause(); + new Handler(looper) + .post( + () -> { + throw new IllegalStateException(); + }); + + assertThrows(IllegalStateException.class, () -> shadowOf(looper).idle()); + handlerThread.join(5_000); + assertThat(handlerThread.getState()).isEqualTo(Thread.State.TERMINATED); + } + + @Test + public void idle_looperRunning_runnableThrowsException() throws Exception { + Looper looper = handlerThread.getLooper(); + new Handler(looper) + .post( + () -> { + throw new IllegalStateException(); + }); + + assertThrows(IllegalStateException.class, () -> shadowOf(looper).idle()); + handlerThread.join(5_000); + assertThat(handlerThread.getState()).isEqualTo(Thread.State.TERMINATED); + } + + @Test + public void post_throws_if_looper_died() throws Exception { + Looper looper = handlerThread.getLooper(); + new Handler(looper) + .post( + () -> { + throw new IllegalStateException(); + }); + handlerThread.join(5_000); + assertThat(handlerThread.getState()).isEqualTo(Thread.State.TERMINATED); + + assertThrows(IllegalStateException.class, () -> new Handler(looper).post(() -> {})); + } + + @Test + public void idle_throws_if_looper_died() throws Exception { + Looper looper = handlerThread.getLooper(); + new Handler(looper) + .post( + () -> { + throw new IllegalStateException(); + }); + handlerThread.join(5_000); + assertThat(handlerThread.getState()).isEqualTo(Thread.State.TERMINATED); + + assertThrows(IllegalStateException.class, () -> shadowOf(looper).idle()); } @Test diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java index 4e7fb16ce..e3b48f0f1 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSubscriptionManagerTest.java @@ -331,6 +331,48 @@ public class ShadowSubscriptionManagerTest { .isEqualTo("123"); } + @Test + @Config(minSdk = TIRAMISU) + public void getPhoneNumberWithSource_phoneNumberNotSet_returnsEmptyString() { + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_UICC)) + .isEqualTo(""); + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER)) + .isEqualTo(""); + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_IMS)) + .isEqualTo(""); + } + + @Test + @Config(minSdk = TIRAMISU) + public void getPhoneNumberWithSource_setPhoneNumber_returnsPhoneNumber() { + shadowOf(subscriptionManager) + .setPhoneNumber(SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, "123"); + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_UICC)) + .isEqualTo("123"); + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER)) + .isEqualTo("123"); + assertThat( + subscriptionManager.getPhoneNumber( + SubscriptionManager.DEFAULT_SUBSCRIPTION_ID, + SubscriptionManager.PHONE_NUMBER_SOURCE_IMS)) + .isEqualTo("123"); + } + private static class DummySubscriptionsChangedListener extends SubscriptionManager.OnSubscriptionsChangedListener { private int subscriptionChangedCount; diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java index 8571627a4..f6b09950d 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java @@ -374,6 +374,21 @@ public class ShadowTelephonyManagerTest { } @Test + @Config(minSdk = S) + public void shouldGiveCallStateForSubscription() { + PhoneStateListener listener = mock(PhoneStateListener.class); + telephonyManager.listen(listener, LISTEN_CALL_STATE); + + shadowOf(telephonyManager).setCallState(CALL_STATE_RINGING, "911"); + assertEquals(CALL_STATE_RINGING, telephonyManager.getCallStateForSubscription()); + verify(listener).onCallStateChanged(CALL_STATE_RINGING, "911"); + + shadowOf(telephonyManager).setCallState(CALL_STATE_OFFHOOK, "911"); + assertEquals(CALL_STATE_OFFHOOK, telephonyManager.getCallStateForSubscription()); + verify(listener).onCallStateChanged(CALL_STATE_OFFHOOK, null); + } + + @Test public void shouldGiveCallState() { PhoneStateListener listener = mock(PhoneStateListener.class); telephonyManager.listen(listener, LISTEN_CALL_STATE); @@ -803,6 +818,24 @@ public class ShadowTelephonyManagerTest { } @Test + @Config(minSdk = S) + public void setDataEnabledForReasonChangesIsDataEnabledForReason() { + int correctReason = TelephonyManager.DATA_ENABLED_REASON_POLICY; + int incorrectReason = TelephonyManager.DATA_ENABLED_REASON_USER; + + assertThat(telephonyManager.isDataEnabledForReason(correctReason)).isTrue(); + assertThat(telephonyManager.isDataEnabledForReason(incorrectReason)).isTrue(); + + telephonyManager.setDataEnabledForReason(correctReason, false); + assertThat(telephonyManager.isDataEnabledForReason(correctReason)).isFalse(); + assertThat(telephonyManager.isDataEnabledForReason(incorrectReason)).isTrue(); + + telephonyManager.setDataEnabledForReason(correctReason, true); + assertThat(telephonyManager.isDataEnabledForReason(correctReason)).isTrue(); + assertThat(telephonyManager.isDataEnabledForReason(incorrectReason)).isTrue(); + } + + @Test public void setDataStateChangesDataState() { assertThat(telephonyManager.getDataState()).isEqualTo(TelephonyManager.DATA_DISCONNECTED); shadowOf(telephonyManager).setDataState(TelephonyManager.DATA_CONNECTING); diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java index 75df1101a..7c9bfafca 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowUserManagerTest.java @@ -9,6 +9,7 @@ import static android.os.Build.VERSION_CODES.N; import static android.os.Build.VERSION_CODES.N_MR1; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; import static org.robolectric.Shadows.shadowOf; @@ -68,7 +69,8 @@ public class ShadowUserManagerTest { UserHandle anotherProfile = newUserHandle(2); shadowOf(userManager).addUserProfile(anotherProfile); - assertThat(userManager.getUserProfiles()).containsExactly(Process.myUserHandle(), anotherProfile); + assertThat(userManager.getUserProfiles()) + .containsExactly(Process.myUserHandle(), anotherProfile); } @Test @@ -243,7 +245,8 @@ public class ShadowUserManagerTest { try { userManager.isManagedProfile(); fail("Expected exception"); - } catch (SecurityException expected) {} + } catch (SecurityException expected) { + } setPermissions(permission.MANAGE_USERS); @@ -317,6 +320,19 @@ public class ShadowUserManagerTest { } @Test + @Config(minSdk = R) + public void getUserHandles() { + assertThat(shadowOf(userManager).getUserHandles(/* excludeDying= */ true).size()).isEqualTo(1); + shadowOf(userManager).getUserHandles(/* excludeDying= */ true).get(0); + assertThat(UserHandle.myUserId()).isEqualTo(UserHandle.USER_SYSTEM); + + UserHandle expectedUserHandle = shadowOf(userManager).addUser(10, "secondary_user", 0); + assertThat(shadowOf(userManager).getUserHandles(/* excludeDying= */ true).size()).isEqualTo(2); + assertThat(shadowOf(userManager).getUserHandles(/* excludeDying= */ true).get(1)) + .isEqualTo(expectedUserHandle); + } + + @Test @Config(minSdk = N_MR1, maxSdk = Q) public void isDemoUser() { // All methods are based on the current user, so no need to pass a UserHandle. @@ -565,6 +581,34 @@ public class ShadowUserManagerTest { } @Test + @Config(minSdk = Q) + public void removeSecondaryUser_noExistingUser_doesNotRemove() { + assertThat(shadowOf(userManager).removeUser(UserHandle.of(10))).isFalse(); + assertThat(userManager.getUserCount()).isEqualTo(1); + } + + @Test + @Config(minSdk = TIRAMISU) + public void removeUserWhenPossible_twoUsersRemoveOne_hasOneUserLeft() { + shadowOf(userManager).addUser(10, "secondary_user", 0); + assertThat( + userManager.removeUserWhenPossible( + UserHandle.of(10), /* overrideDevicePolicy= */ false)) + .isEqualTo(UserManager.REMOVE_RESULT_REMOVED); + assertThat(userManager.getUserCount()).isEqualTo(1); + } + + @Test + @Config(minSdk = TIRAMISU) + public void removeUserWhenPossible_nonExistingUser_fails() { + assertThat( + userManager.removeUserWhenPossible( + UserHandle.of(10), /* overrideDevicePolicy= */ false)) + .isEqualTo(UserManager.REMOVE_RESULT_ERROR_UNKNOWN); + assertThat(userManager.getUserCount()).isEqualTo(1); + } + + @Test @Config(minSdk = JELLY_BEAN_MR1) public void switchToSecondaryUser() { shadowOf(userManager).addUser(10, "secondary_user", 0); @@ -653,8 +697,8 @@ public class ShadowUserManagerTest { @Config(minSdk = LOLLIPOP) public void getProfiles_addedProfile_containsProfile() { shadowOf(userManager).addUser(TEST_USER_HANDLE, "", 0); - shadowOf(userManager).addProfile( - TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS); + shadowOf(userManager) + .addProfile(TEST_USER_HANDLE, PROFILE_USER_HANDLE, PROFILE_USER_NAME, PROFILE_USER_FLAGS); // getProfiles(userId) include user itself and asssociated profiles. assertThat(userManager.getProfiles(TEST_USER_HANDLE).get(0).id).isEqualTo(TEST_USER_HANDLE); @@ -850,7 +894,6 @@ public class ShadowUserManagerTest { assertThat(UserManager.supportsMultipleUsers()).isTrue(); } - @Test @Config(minSdk = Q) public void getUserSwitchability_shouldReturnLastSetSwitchability() { @@ -859,8 +902,7 @@ public class ShadowUserManagerTest { .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED); assertThat(userManager.getUserSwitchability()) .isEqualTo(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED); - shadowOf(userManager) - .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK); + shadowOf(userManager).setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK); assertThat(userManager.getUserSwitchability()).isEqualTo(UserManager.SWITCHABILITY_STATUS_OK); } @@ -880,8 +922,7 @@ public class ShadowUserManagerTest { shadowOf(userManager) .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_USER_SWITCH_DISALLOWED); assertThat(userManager.canSwitchUsers()).isFalse(); - shadowOf(userManager) - .setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK); + shadowOf(userManager).setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK); assertThat(userManager.canSwitchUsers()).isTrue(); } @@ -889,7 +930,7 @@ public class ShadowUserManagerTest { @Config(minSdk = Q) public void getUserName_shouldReturnSetUserName() { shadowOf(userManager).setUserSwitchability(UserManager.SWITCHABILITY_STATUS_OK); - shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags = */ 0); + shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags= */ 0); shadowOf(userManager).switchUser(10); assertThat(userManager.getUserName()).isEqualTo(PROFILE_USER_NAME); } @@ -900,7 +941,7 @@ public class ShadowUserManagerTest { userManager.setUserIcon(TEST_USER_ICON); assertThat(userManager.getUserIcon()).isEqualTo(TEST_USER_ICON); - shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags = */ 0); + shadowOf(userManager).addUser(10, PROFILE_USER_NAME, /* flags= */ 0); shadowOf(userManager).switchUser(10); assertThat(userManager.getUserIcon()).isNull(); } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnManagerTest.java new file mode 100644 index 000000000..dbf7250ef --- /dev/null +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowVpnManagerTest.java @@ -0,0 +1,95 @@ +package org.robolectric.shadows; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Intent; +import android.net.Ikev2VpnProfile; +import android.net.VpnManager; +import android.net.VpnProfileState; +import android.os.Build.VERSION_CODES; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(AndroidJUnit4.class) +@Config(minSdk = VERSION_CODES.R) +public class ShadowVpnManagerTest { + private VpnManager vpnManager; + private ShadowVpnManager shadowVpnManager; + + @Before + public void setUp() throws Exception { + vpnManager = ApplicationProvider.getApplicationContext().getSystemService(VpnManager.class); + shadowVpnManager = shadowOf(vpnManager); + } + + @Test + public void provisionVpnProfile() { + Intent intent = new Intent("foo"); + shadowVpnManager.setProvisionVpnProfileResult(intent); + + assertThat( + vpnManager.provisionVpnProfile( + new Ikev2VpnProfile.Builder("server", "local.identity") + .setAuthPsk(new byte[0]) + .build())) + .isSameInstanceAs(intent); + + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.TIRAMISU) { + VpnProfileState state = vpnManager.getProvisionedVpnProfileState(); + assertThat(state.getState()).isEqualTo(VpnProfileState.STATE_DISCONNECTED); + assertThat(state.getSessionId()).isNull(); + } + } + + @Test + public void deleteVpnProfile() { + vpnManager.provisionVpnProfile( + new Ikev2VpnProfile.Builder("server", "local.identity").setAuthPsk(new byte[0]).build()); + vpnManager.deleteProvisionedVpnProfile(); + } + + @Test + @Config(minSdk = VERSION_CODES.TIRAMISU) + public void deleteVpnProfile_tiramisu() { + vpnManager.provisionVpnProfile( + new Ikev2VpnProfile.Builder("server", "local.identity").setAuthPsk(new byte[0]).build()); + assertThat(vpnManager.getProvisionedVpnProfileState()).isNotNull(); + + vpnManager.deleteProvisionedVpnProfile(); + assertThat(vpnManager.getProvisionedVpnProfileState()).isNull(); + } + + @Test + public void startAndStopVpnProfile() { + vpnManager.provisionVpnProfile( + new Ikev2VpnProfile.Builder("server", "local.identity").setAuthPsk(new byte[0]).build()); + vpnManager.startProvisionedVpnProfile(); + vpnManager.stopProvisionedVpnProfile(); + } + + @Test + @Config(minSdk = VERSION_CODES.TIRAMISU) + public void startAndStopVpnProfile_tiramisu() { + vpnManager.provisionVpnProfile( + new Ikev2VpnProfile.Builder("server", "local.identity").setAuthPsk(new byte[0]).build()); + String sessionKey = vpnManager.startProvisionedVpnProfileSession(); + VpnProfileState state = vpnManager.getProvisionedVpnProfileState(); + assertThat(state.getState()).isEqualTo(VpnProfileState.STATE_CONNECTED); + assertThat(state.getSessionId()).isEqualTo(sessionKey); + assertThat(state.isAlwaysOn()).isFalse(); + assertThat(state.isLockdownEnabled()).isFalse(); + + vpnManager.stopProvisionedVpnProfile(); + state = vpnManager.getProvisionedVpnProfileState(); + assertThat(state.getState()).isEqualTo(VpnProfileState.STATE_DISCONNECTED); + assertThat(state.getSessionId()).isNull(); + assertThat(state.isAlwaysOn()).isFalse(); + assertThat(state.isLockdownEnabled()).isFalse(); + } +} diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java index 7a1bee691..15dd9ec96 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowWifiManagerTest.java @@ -1,12 +1,17 @@ package org.robolectric.shadows; +import static android.net.wifi.WifiManager.SCAN_RESULTS_AVAILABLE_ACTION; import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static com.google.common.truth.Truth.assertThat; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -14,10 +19,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.robolectric.Shadows.shadowOf; +import android.app.Application; import android.app.admin.DeviceAdminService; import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.net.ConnectivityManager; import android.net.DhcpInfo; import android.net.NetworkInfo; @@ -27,13 +34,17 @@ import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.MulticastLock; +import android.net.wifi.WifiManager.PnoScanResultsCallback; +import android.net.wifi.WifiSsid; import android.net.wifi.WifiUsabilityStatsEntry; import android.os.Build; import android.util.Pair; -import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -47,9 +58,7 @@ public class ShadowWifiManagerTest { @Before public void setUp() throws Exception { - wifiManager = - (WifiManager) - ApplicationProvider.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); } @Test @@ -494,8 +503,7 @@ public class ShadowWifiManagerTest { // THEN NetworkInfo networkInfo = ((ConnectivityManager) - ApplicationProvider.getApplicationContext() - .getSystemService(Context.CONNECTIVITY_SERVICE)) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE)) .getActiveNetworkInfo(); assertThat(networkInfo.getType()).isEqualTo(ConnectivityManager.TYPE_WIFI); assertThat(networkInfo.isConnected()).isTrue(); @@ -784,13 +792,305 @@ public class ShadowWifiManagerTest { assertThat(shadowOf(wifiManager).getSoftApConfiguration().getSsid()).isEqualTo("foo"); } + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_nullCallback_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {3, 2, 5})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + /* callback= */ null)); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_nullExecutor_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {3, 2, 5})), + /* frequencies= */ null, + /* executor= */ null, + new TestPnoScanResultsCallback())); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_nullSsidList_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> + wifiManager.setExternalPnoScanRequest( + /* ssids= */ null, + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + new TestPnoScanResultsCallback())); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_emptySsidList_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> + wifiManager.setExternalPnoScanRequest( + /* ssids= */ List.of(), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + new TestPnoScanResultsCallback())); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_moreThan2Ssids_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + wifiManager.setExternalPnoScanRequest( + List.of( + WifiSsid.fromBytes(new byte[] {1, 2, 3}), + WifiSsid.fromBytes(new byte[] {9, 8, 7, 6}), + WifiSsid.fromBytes(new byte[] {90, 81, 72, 63, 54})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + new TestPnoScanResultsCallback())); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_moreThan10Frequencies_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + wifiManager.setExternalPnoScanRequest( + List.of( + WifiSsid.fromBytes(new byte[] {1, 2, 3}), + WifiSsid.fromBytes(new byte[] {9, 8, 7, 6})), + new int[] {5160, 5180, 5200, 5220, 5240, 5260, 5280, 5300, 5320, 5340, 5360}, + Executors.newSingleThreadExecutor(), + new TestPnoScanResultsCallback())); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_validRequest_successCallbackInvoked() throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + callback); + + assertThat(callback.successfulRegistrations.take()).isNotNull(); + } + + @Test + @Config(minSdk = TIRAMISU) + public void + setExternalPnoScanRequest_outstandingRequest_failureCallbackInvokedWithAlreadyRegisteredStatus() + throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + callback); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {9, 2, 5})), + new int[] {5280}, + Executors.newSingleThreadExecutor(), + callback); + + assertThat(callback.failedRegistrations.take()) + .isEqualTo(PnoScanResultsCallback.REGISTER_PNO_CALLBACK_ALREADY_REGISTERED); + } + + @Test + @Config(minSdk = TIRAMISU) + public void setExternalPnoScanRequest_differentUid_failureCallbackInvokedWithBusyStatus() + throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + callback); + + int firstAppUid = ShadowProcess.myUid(); + int secondAppUid; + do { + secondAppUid = ShadowProcess.getRandomApplicationUid(); + } while (firstAppUid == secondAppUid); + ShadowProcess.setUid(secondAppUid); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + callback); + + assertThat(callback.failedRegistrations.take()) + .isEqualTo(PnoScanResultsCallback.REGISTER_PNO_CALLBACK_RESOURCE_BUSY); + } + + @Test + @Config(minSdk = TIRAMISU) + public void clearExternalPnoScanRequest_outstandingRequest_callbackInvokedWithUnregisteredStatus() + throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + Executors.newSingleThreadExecutor(), + callback); + wifiManager.clearExternalPnoScanRequest(); + + assertThat(callback.removedRegistrations.take()) + .isEqualTo(PnoScanResultsCallback.REMOVE_PNO_CALLBACK_UNREGISTERED); + } + + @Test + @Config(minSdk = TIRAMISU) + public void clearExternalPnoScanRequest_wrongUid_callbackNotInvoked() throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + wifiManager.setExternalPnoScanRequest( + List.of(WifiSsid.fromBytes(new byte[] {1, 2, 3})), + /* frequencies= */ null, + executor, + callback); + + int firstAppUid = ShadowProcess.myUid(); + int secondAppUid; + do { + secondAppUid = ShadowProcess.getRandomApplicationUid(); + } while (firstAppUid == secondAppUid); + ShadowProcess.setUid(secondAppUid); + + wifiManager.clearExternalPnoScanRequest(); + + executor.shutdown(); + + assertThat(executor.awaitTermination(5, MINUTES)).isTrue(); + assertThat(callback.removedRegistrations).isEmpty(); + } + + @Test + @Config(minSdk = TIRAMISU) + public void networksFoundFromPnoScan_matchingSsid_availableCallbackInvoked() throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + WifiSsid wifiSsid = WifiSsid.fromBytes(new byte[] {1, 2, 3}); + ScanResult scanResult = new ScanResult(); + scanResult.setWifiSsid(wifiSsid); + + wifiManager.setExternalPnoScanRequest( + List.of(wifiSsid), /* frequencies= */ null, Executors.newSingleThreadExecutor(), callback); + shadowOf(wifiManager).networksFoundFromPnoScan(List.of(scanResult)); + + assertThat(callback.incomingScanResults.take()).containsExactly(scanResult); + } + + @Test + @Config(minSdk = TIRAMISU) + public void networksFoundFromPnoScan_matchingSsid_removedCallbackInvokedWithDeliveredStatus() + throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + WifiSsid wifiSsid = WifiSsid.fromBytes(new byte[] {1, 2, 3}); + ScanResult scanResult = new ScanResult(); + scanResult.setWifiSsid(wifiSsid); + + wifiManager.setExternalPnoScanRequest( + List.of(wifiSsid), /* frequencies= */ null, Executors.newSingleThreadExecutor(), callback); + shadowOf(wifiManager).networksFoundFromPnoScan(List.of(scanResult)); + + assertThat(callback.removedRegistrations.take()) + .isEqualTo(PnoScanResultsCallback.REMOVE_PNO_CALLBACK_RESULTS_DELIVERED); + } + + @Test + @Config(minSdk = TIRAMISU) + public void networksFoundFromPnoScan_matchingSsid_scanResultsAvailableBroadcastSent() { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + WifiSsid wifiSsid = WifiSsid.fromBytes(new byte[] {1, 2, 3}); + ScanResult scanResult = new ScanResult(); + scanResult.setWifiSsid(wifiSsid); + + wifiManager.setExternalPnoScanRequest( + List.of(wifiSsid), /* frequencies= */ null, Executors.newSingleThreadExecutor(), callback); + shadowOf(wifiManager).networksFoundFromPnoScan(List.of(scanResult)); + + Intent expectedIntent = new Intent(SCAN_RESULTS_AVAILABLE_ACTION); + expectedIntent.putExtra(WifiManager.EXTRA_RESULTS_UPDATED, true); + expectedIntent.setPackage(getApplicationContext().getPackageName()); + + assertThat( + shadowOf((Application) getApplicationContext()).getBroadcastIntents().stream() + .anyMatch(expectedIntent::filterEquals)) + .isTrue(); + } + + @Test + @Config(minSdk = TIRAMISU) + public void networksFoundFromPnoScan_noMatchingSsid_availableCallbackNotInvoked() + throws Exception { + TestPnoScanResultsCallback callback = new TestPnoScanResultsCallback(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + WifiSsid wifiSsid = WifiSsid.fromBytes(new byte[] {1, 2, 3}); + WifiSsid otherWifiSsid = WifiSsid.fromBytes(new byte[] {9, 8, 7, 6}); + ScanResult scanResult = new ScanResult(); + scanResult.setWifiSsid(otherWifiSsid); + + wifiManager.setExternalPnoScanRequest( + List.of(wifiSsid), /* frequencies= */ null, executor, callback); + shadowOf(wifiManager).networksFoundFromPnoScan(List.of(scanResult)); + + executor.shutdown(); + + assertThat(executor.awaitTermination(5, MINUTES)).isTrue(); + assertThat(callback.incomingScanResults).isEmpty(); + } + + private class TestPnoScanResultsCallback implements PnoScanResultsCallback { + LinkedBlockingQueue<List<ScanResult>> incomingScanResults = new LinkedBlockingQueue<>(); + LinkedBlockingQueue<Object> successfulRegistrations = new LinkedBlockingQueue<>(); + LinkedBlockingQueue<Integer> failedRegistrations = new LinkedBlockingQueue<>(); + LinkedBlockingQueue<Integer> removedRegistrations = new LinkedBlockingQueue<>(); + + @Override + public void onScanResultsAvailable(List<ScanResult> scanResults) { + incomingScanResults.add(scanResults); + } + + @Override + public void onRegisterSuccess() { + successfulRegistrations.add(new Object()); + } + + @Override + public void onRegisterFailed(int reason) { + failedRegistrations.add(reason); + } + + @Override + public void onRemoved(int reason) { + removedRegistrations.add(reason); + } + } + private void setDeviceOwner() { shadowOf( (DevicePolicyManager) - ApplicationProvider.getApplicationContext() - .getSystemService(Context.DEVICE_POLICY_SERVICE)) - .setDeviceOwner( - new ComponentName( - ApplicationProvider.getApplicationContext(), DeviceAdminService.class)); + getApplicationContext().getSystemService(Context.DEVICE_POLICY_SERVICE)) + .setDeviceOwner(new ComponentName(getApplicationContext(), DeviceAdminService.class)); } } diff --git a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java index fac00226d..e1463a17b 100644 --- a/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java +++ b/sandbox/src/main/java/org/robolectric/internal/bytecode/ClassInstrumentor.java @@ -10,6 +10,7 @@ import java.lang.invoke.MethodType; import java.lang.reflect.Modifier; import java.util.List; import java.util.ListIterator; +import java.util.Objects; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.ConstantDynamic; @@ -212,23 +213,25 @@ public class ClassInstrumentor { } /** - * Checks if the first instruction is a Jacoco load instructions. Robolectric is not capable at - * the moment of re-instrumenting Jacoco-instrumented constructors. + * Checks if the first or second instruction is a Jacoco load instruction. Robolectric is not + * capable at the moment of re-instrumenting Jacoco-instrumented constructors, so these are + * currently skipped. * * @param ctor constructor method node * @return whether or not the constructor can be instrumented */ private boolean isJacocoInstrumented(MethodNode ctor) { AbstractInsnNode[] insns = ctor.instructions.toArray(); - if (insns.length > 0) { - if (insns[0] instanceof LdcInsnNode - && ((LdcInsnNode) insns[0]).cst instanceof ConstantDynamic) { - ConstantDynamic cst = (ConstantDynamic) ((LdcInsnNode) insns[0]).cst; + if (insns.length > 1) { + AbstractInsnNode node = insns[0]; + if (node instanceof LabelNode) { + node = insns[1]; + } + if ((node instanceof LdcInsnNode && ((LdcInsnNode) node).cst instanceof ConstantDynamic)) { + ConstantDynamic cst = (ConstantDynamic) ((LdcInsnNode) node).cst; return cst.getName().equals("$jacocoData"); - } else if (insns.length > 1 - && insns[0] instanceof LabelNode - && insns[1] instanceof MethodInsnNode) { - return "$jacocoInit".equals(((MethodInsnNode) insns[1]).name); + } else if (node instanceof MethodInsnNode) { + return Objects.equals(((MethodInsnNode) node).name, "$jacocoInit"); } } return false; diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/AssociationInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/AssociationInfoBuilder.java new file mode 100644 index 000000000..73c36a542 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/AssociationInfoBuilder.java @@ -0,0 +1,117 @@ +package org.robolectric.shadows; + +import static android.os.Build.VERSION_CODES.TIRAMISU; + +import android.companion.AssociationInfo; +import android.net.MacAddress; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.ReflectionHelpers; +import org.robolectric.util.ReflectionHelpers.ClassParameter; + +/** Builder for {@link AssociationInfo}. */ +public class AssociationInfoBuilder { + private int id; + private int userId; + private String packageName; + private String deviceMacAddress; + private CharSequence displayName; + private String deviceProfile; + private boolean selfManaged; + private boolean notifyOnDeviceNearby; + private long approvedMs; + private long lastTimeConnectedMs; + + private AssociationInfoBuilder() {} + + public static AssociationInfoBuilder newBuilder() { + return new AssociationInfoBuilder(); + } + + public AssociationInfoBuilder setId(int id) { + this.id = id; + return this; + } + + public AssociationInfoBuilder setUserId(int userId) { + this.userId = userId; + return this; + } + + public AssociationInfoBuilder setPackageName(String packageName) { + this.packageName = packageName; + return this; + } + + public AssociationInfoBuilder setDeviceMacAddress(String deviceMacAddress) { + this.deviceMacAddress = deviceMacAddress; + return this; + } + + public AssociationInfoBuilder setDisplayName(CharSequence displayName) { + this.displayName = displayName; + return this; + } + + public AssociationInfoBuilder setDeviceProfile(String deviceProfile) { + this.deviceProfile = deviceProfile; + return this; + } + + public AssociationInfoBuilder setSelfManaged(boolean selfManaged) { + this.selfManaged = selfManaged; + return this; + } + + public AssociationInfoBuilder setNotifyOnDeviceNearby(boolean notifyOnDeviceNearby) { + this.notifyOnDeviceNearby = notifyOnDeviceNearby; + return this; + } + + public AssociationInfoBuilder setApprovedMs(long approvedMs) { + this.approvedMs = approvedMs; + return this; + } + + public AssociationInfoBuilder setLastTimeConnectedMs(long lastTimeConnectedMs) { + this.lastTimeConnectedMs = lastTimeConnectedMs; + return this; + } + + public AssociationInfo build() { + try { + if (RuntimeEnvironment.getApiLevel() <= TIRAMISU) { + return ReflectionHelpers.callConstructor( + AssociationInfo.class, + ClassParameter.from(int.class, id), + ClassParameter.from(int.class, userId), + ClassParameter.from(String.class, packageName), + ClassParameter.from(MacAddress.class, MacAddress.fromString(deviceMacAddress)), + ClassParameter.from(CharSequence.class, displayName), + ClassParameter.from(String.class, deviceProfile), + ClassParameter.from(boolean.class, selfManaged), + ClassParameter.from(boolean.class, notifyOnDeviceNearby), + ClassParameter.from(boolean.class, false /*revoked*/), + ClassParameter.from(long.class, approvedMs), + ClassParameter.from(long.class, lastTimeConnectedMs)); + } else { + return ReflectionHelpers.callConstructor( + AssociationInfo.class, + ClassParameter.from(int.class, id), + ClassParameter.from(int.class, userId), + ClassParameter.from(String.class, packageName), + ClassParameter.from(MacAddress.class, MacAddress.fromString(deviceMacAddress)), + ClassParameter.from(CharSequence.class, displayName), + ClassParameter.from(String.class, deviceProfile), + ClassParameter.from(Class.forName("android.companion.AssociatedDevice"), null), + ClassParameter.from(boolean.class, selfManaged), + ClassParameter.from(boolean.class, notifyOnDeviceNearby), + ClassParameter.from(boolean.class, false /*revoked*/), + ClassParameter.from(long.class, approvedMs), + ClassParameter.from(long.class, lastTimeConnectedMs), + ClassParameter.from(int.class, 0 /*systemDataSyncFlags*/)); + } + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } +} diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/BluetoothConnectionManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/BluetoothConnectionManager.java new file mode 100644 index 000000000..70e54b190 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/BluetoothConnectionManager.java @@ -0,0 +1,139 @@ +package org.robolectric.shadows; + +import java.util.HashMap; +import java.util.Map; + +/** + * Manages remote address connections for {@link ShadowBluetoothGatt} and {@link + * ShadowBluetoothGattServer}. + */ +final class BluetoothConnectionManager { + + private static volatile BluetoothConnectionManager instance; + + /** Connection metadata for Gatt Server and Client connections. */ + private static class BluetoothConnectionMetadata { + boolean hasGattClientConnection = false; + boolean hasGattServerConnection = false; + + void setHasGattClientConnection(boolean hasGattClientConnection) { + this.hasGattClientConnection = hasGattClientConnection; + } + + void setHasGattServerConnection(boolean hasGattServerConnection) { + this.hasGattServerConnection = hasGattServerConnection; + } + + boolean hasGattClientConnection() { + return hasGattClientConnection; + } + + boolean hasGattServerConnection() { + return hasGattServerConnection; + } + + boolean isConnected() { + return hasGattClientConnection || hasGattServerConnection; + } + } + + private BluetoothConnectionManager() {} + + static BluetoothConnectionManager getInstance() { + if (instance == null) { + synchronized (BluetoothConnectionManager.class) { + if (instance == null) { + instance = new BluetoothConnectionManager(); + } + } + } + return instance; + } + + /** + * Map representing remote address connections, mapping a remote address to a {@link + * BluetoothConnectionMetadata}. + */ + private final Map<String, BluetoothConnectionMetadata> remoteAddressConnectionMap = + new HashMap<>(); + + /** + * Register a Gatt Client Connection. Intended for use by {@link + * ShadowBluetoothGatt#notifyConnection} when simulating a successful Gatt Client Connection. + */ + void registerGattClientConnection(String remoteAddress) { + if (!remoteAddressConnectionMap.containsKey(remoteAddress)) { + remoteAddressConnectionMap.put(remoteAddress, new BluetoothConnectionMetadata()); + } + remoteAddressConnectionMap.get(remoteAddress).setHasGattClientConnection(true); + } + + /** + * Unregister a Gatt Client Connection. Intended for use by {@link + * ShadowBluetoothGatt#notifyDisconnection} when simulating a successful Gatt client + * disconnection. + */ + void unregisterGattClientConnection(String remoteAddress) { + if (remoteAddressConnectionMap.containsKey(remoteAddress)) { + remoteAddressConnectionMap.get(remoteAddress).setHasGattClientConnection(false); + } + } + + /** + * Register a Gatt Server Connection. Intended for use by {@link + * ShadowBluetoothGattServer#notifyConnection} when simulating a successful Gatt server + * connection. + */ + void registerGattServerConnection(String remoteAddress) { + if (!remoteAddressConnectionMap.containsKey(remoteAddress)) { + remoteAddressConnectionMap.put(remoteAddress, new BluetoothConnectionMetadata()); + } + remoteAddressConnectionMap.get(remoteAddress).setHasGattServerConnection(true); + } + + /** + * Unregister a Gatt Server Connection. Intended for use by {@link + * ShadowBluetoothGattServer#notifyDisconnection} when simulating a successful Gatt server + * disconnection. + */ + void unregisterGattServerConnection(String remoteAddress) { + if (remoteAddressConnectionMap.containsKey(remoteAddress)) { + remoteAddressConnectionMap.get(remoteAddress).setHasGattServerConnection(false); + } + } + + /** + * Returns true if remote address has an active gatt client connection. + * + * @param remoteAddress remote address + */ + boolean hasGattClientConnection(String remoteAddress) { + return remoteAddressConnectionMap.containsKey(remoteAddress) + && remoteAddressConnectionMap.get(remoteAddress).hasGattClientConnection(); + } + + /** + * Returns true if remote address has an active gatt server connection. + * + * @param remoteAddress remote address + */ + boolean hasGattServerConnection(String remoteAddress) { + return remoteAddressConnectionMap.containsKey(remoteAddress) + && remoteAddressConnectionMap.get(remoteAddress).hasGattServerConnection(); + } + + /** + * Returns true if remote address has an active connection. + * + * @param remoteAddress remote address + */ + boolean isConnected(String remoteAddress) { + return remoteAddressConnectionMap.containsKey(remoteAddress) + && remoteAddressConnectionMap.get(remoteAddress).isConnected(); + } + + /** Clears all connection information */ + void resetConnections() { + this.remoteAddressConnectionMap.clear(); + } +}
\ No newline at end of file diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/CellIdentityLteBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellIdentityLteBuilder.java new file mode 100644 index 000000000..597aef246 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellIdentityLteBuilder.java @@ -0,0 +1,170 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.telephony.CellIdentityLte; +import android.telephony.CellInfo; +import android.telephony.ClosedSubscriberGroupInfo; +import androidx.annotation.RequiresApi; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.Nullable; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.reflector.Constructor; +import org.robolectric.util.reflector.ForType; + +/** Builder for {@link android.telephony.CellIdentityLte}. */ +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellIdentityLteBuilder { + + @Nullable private String mcc = null; + @Nullable private String mnc = null; + private int ci = CellInfo.UNAVAILABLE; + private int pci = CellInfo.UNAVAILABLE; + private int tac = CellInfo.UNAVAILABLE; + private int earfcn = CellInfo.UNAVAILABLE; + private int[] bands = new int[0]; + private int bandwidth = CellInfo.UNAVAILABLE; + @Nullable private String alphal = null; + @Nullable private String alphas = null; + private List<String> additionalPlmns = new ArrayList<>(); + + private CellIdentityLteBuilder() {} + + public static CellIdentityLteBuilder newBuilder() { + return new CellIdentityLteBuilder(); + } + + protected static CellIdentityLte getDefaultInstance() { + return reflector(CellIdentityLteReflector.class).newCellIdentityLte(); + } + + public CellIdentityLteBuilder setMcc(String mcc) { + this.mcc = mcc; + return this; + } + + public CellIdentityLteBuilder setMnc(String mnc) { + this.mnc = mnc; + return this; + } + + public CellIdentityLteBuilder setCi(int ci) { + this.ci = ci; + return this; + } + + public CellIdentityLteBuilder setPci(int pci) { + this.pci = pci; + return this; + } + + public CellIdentityLteBuilder setTac(int tac) { + this.tac = tac; + return this; + } + + public CellIdentityLteBuilder setEarfcn(int earfcn) { + this.earfcn = earfcn; + return this; + } + + public CellIdentityLteBuilder setBands(int[] bands) { + this.bands = bands; + return this; + } + + public CellIdentityLteBuilder setBandwidth(int bandwidth) { + this.bandwidth = bandwidth; + return this; + } + + public CellIdentityLteBuilder setLongOperatorName(String longOperatorName) { + this.alphal = longOperatorName; + return this; + } + + public CellIdentityLteBuilder setShortOperatorName(String shortOperatorName) { + this.alphas = shortOperatorName; + return this; + } + + public CellIdentityLteBuilder setAdditionalPlmns(List<String> additionalPlmns) { + this.additionalPlmns = additionalPlmns; + return this; + } + + public CellIdentityLte build() { + CellIdentityLteReflector cellIdentityLteReflector = reflector(CellIdentityLteReflector.class); + int apiLevel = RuntimeEnvironment.getApiLevel(); + if (apiLevel < Build.VERSION_CODES.N) { + return cellIdentityLteReflector.newCellIdentityLte( + mccOrMncToInt(mcc), mccOrMncToInt(mnc), ci, pci, tac); + } else if (apiLevel < Build.VERSION_CODES.P) { + return cellIdentityLteReflector.newCellIdentityLte( + mccOrMncToInt(mcc), mccOrMncToInt(mnc), ci, pci, tac, earfcn); + } else if (apiLevel < Build.VERSION_CODES.R) { + return cellIdentityLteReflector.newCellIdentityLte( + ci, pci, tac, earfcn, bandwidth, mcc, mnc, alphal, alphas); + } else { + return cellIdentityLteReflector.newCellIdentityLte( + ci, + pci, + tac, + earfcn, + bands, + bandwidth, + mcc, + mnc, + alphal, + alphas, + additionalPlmns, + /* csgInfo= */ null); + } + } + + private static int mccOrMncToInt(@Nullable String mccOrMnc) { + return mccOrMnc == null ? CellInfo.UNAVAILABLE : Integer.parseInt(mccOrMnc); + } + + @ForType(CellIdentityLte.class) + private interface CellIdentityLteReflector { + @Constructor + CellIdentityLte newCellIdentityLte(); + + @Constructor + CellIdentityLte newCellIdentityLte(int mcc, int mnc, int ci, int pci, int tac); + + @Constructor + CellIdentityLte newCellIdentityLte(int mcc, int mnc, int ci, int pci, int tac, int earfcn); + + @Constructor + CellIdentityLte newCellIdentityLte( + int ci, + int pci, + int tac, + int earfcn, + int bandwidth, + String mcc, + String mnc, + String alphal, + String alphas); + + @Constructor + CellIdentityLte newCellIdentityLte( + int ci, + int pci, + int tac, + int earfcn, + int[] bands, + int bandwidth, + String mcc, + String mnc, + String alphal, + String alphas, + Collection<String> additionalPlmns, + ClosedSubscriberGroupInfo csgInfo); + } +} diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoLteBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoLteBuilder.java new file mode 100644 index 000000000..412d8c72f --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoLteBuilder.java @@ -0,0 +1,139 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.telephony.CellIdentityLte; +import android.telephony.CellInfo; +import android.telephony.CellInfoLte; +import android.telephony.CellSignalStrengthLte; +import androidx.annotation.RequiresApi; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.ReflectionHelpers; +import org.robolectric.util.reflector.Accessor; +import org.robolectric.util.reflector.Constructor; +import org.robolectric.util.reflector.ForType; +import org.robolectric.util.reflector.WithType; + +/** Builder for {@link android.telephony.CellInfoLte}. */ +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellInfoLteBuilder { + + private boolean isRegistered = false; + private long timeStamp = 0L; + private int cellConnectionStatus = 0; + private CellIdentityLte cellIdentity; + private CellSignalStrengthLte cellSignalStrength; + + private CellInfoLteBuilder() {} + + public static CellInfoLteBuilder newBuilder() { + return new CellInfoLteBuilder(); + } + + public CellInfoLteBuilder setRegistered(boolean isRegistered) { + this.isRegistered = isRegistered; + return this; + } + + public CellInfoLteBuilder setTimeStampNanos(long timeStamp) { + this.timeStamp = timeStamp; + return this; + } + + public CellInfoLteBuilder setCellConnectionStatus(int cellConnectionStatus) { + this.cellConnectionStatus = cellConnectionStatus; + return this; + } + + public CellInfoLteBuilder setCellIdentity(CellIdentityLte cellIdentity) { + this.cellIdentity = cellIdentity; + return this; + } + + public CellInfoLteBuilder setCellSignalStrength(CellSignalStrengthLte cellSignalStrength) { + this.cellSignalStrength = cellSignalStrength; + return this; + } + + public CellInfoLte build() { + if (cellIdentity == null) { + cellIdentity = CellIdentityLteBuilder.getDefaultInstance(); + } + if (cellSignalStrength == null) { + cellSignalStrength = CellSignalStrengthLteBuilder.getDefaultInstance(); + } + int apiLevel = RuntimeEnvironment.getApiLevel(); + CellInfoLteReflector cellInfoLteReflector = reflector(CellInfoLteReflector.class); + if (apiLevel < Build.VERSION_CODES.TIRAMISU) { + CellInfoLte cellInfo = cellInfoLteReflector.newCellInfoLte(); + cellInfoLteReflector = reflector(CellInfoLteReflector.class, cellInfo); + cellInfoLteReflector.setCellIdentity(cellIdentity); + cellInfoLteReflector.setCellSignalStrength(cellSignalStrength); + CellInfoReflector cellInfoReflector = reflector(CellInfoReflector.class, cellInfo); + cellInfoReflector.setTimeStamp(timeStamp); + if (apiLevel <= Build.VERSION_CODES.KITKAT) { + cellInfoReflector.setRegisterd(isRegistered); + } else { + cellInfoReflector.setRegistered(isRegistered); + } + if (apiLevel > Build.VERSION_CODES.O_MR1) { + cellInfoReflector.setCellConnectionStatus(cellConnectionStatus); + } + return cellInfo; + } else { + try { + // This reflection is highly brittle but there is currently no choice as CellConfigLte is + // entirely @hide. + Class cellConfigLteClass = Class.forName("android.telephony.CellConfigLte"); + return cellInfoLteReflector.newCellInfoLte( + cellConnectionStatus, + isRegistered, + timeStamp, + cellIdentity, + cellSignalStrength, + ReflectionHelpers.callConstructor(cellConfigLteClass)); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + } + + @ForType(CellInfoLte.class) + private interface CellInfoLteReflector { + @Constructor + CellInfoLte newCellInfoLte(); + + @Constructor + CellInfoLte newCellInfoLte( + int cellConnectionStatus, + boolean isRegistered, + long timeStamp, + CellIdentityLte cellIdentity, + CellSignalStrengthLte cellSignalStrength, + @WithType("android.telephony.CellConfigLte") Object cellConfigLte); + + @Accessor("mCellIdentityLte") + void setCellIdentity(CellIdentityLte cellIdentity); + + @Accessor("mCellSignalStrengthLte") + void setCellSignalStrength(CellSignalStrengthLte cellSignalStrength); + } + + @ForType(CellInfo.class) + private interface CellInfoReflector { + + // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/kitkat-release/telephony/java/android/telephony/CellInfo.java#79 + @Accessor("mRegistered") + void setRegisterd(boolean registered); // NOTYPO + + @Accessor("mRegistered") + void setRegistered(boolean registered); + + @Accessor("mTimeStamp") + void setTimeStamp(long registered); + + @Accessor("mCellConnectionStatus") + void setCellConnectionStatus(int cellConnectionStatus); + } +} diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/CellSignalStrengthLteBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellSignalStrengthLteBuilder.java new file mode 100644 index 000000000..9b5d1a1ac --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellSignalStrengthLteBuilder.java @@ -0,0 +1,96 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.telephony.CellInfo; +import android.telephony.CellSignalStrengthLte; +import androidx.annotation.RequiresApi; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.reflector.Constructor; +import org.robolectric.util.reflector.ForType; + +/** Builder for {@link android.telephony.CellSignalStrengthLte} */ +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +public class CellSignalStrengthLteBuilder { + + private int rssi = CellInfo.UNAVAILABLE; + private int rsrp = CellInfo.UNAVAILABLE; + private int rsrq = CellInfo.UNAVAILABLE; + private int rssnr = CellInfo.UNAVAILABLE; + private int cqiTableIndex = CellInfo.UNAVAILABLE; + private int cqi = CellInfo.UNAVAILABLE; + private int timingAdvance = CellInfo.UNAVAILABLE; + + private CellSignalStrengthLteBuilder() {} + + public static CellSignalStrengthLteBuilder newBuilder() { + return new CellSignalStrengthLteBuilder(); + } + + protected static CellSignalStrengthLte getDefaultInstance() { + return reflector(CellSignalStrengthLteReflector.class).newCellSignalStrength(); + } + + /** This is equivalent to {@code signalStrength} pre SDK Q. */ + public CellSignalStrengthLteBuilder setRssi(int rssi) { + this.rssi = rssi; + return this; + } + + public CellSignalStrengthLteBuilder setRsrp(int rsrp) { + this.rsrp = rsrp; + return this; + } + + public CellSignalStrengthLteBuilder setRsrq(int rsrq) { + this.rsrq = rsrq; + return this; + } + + public CellSignalStrengthLteBuilder setRssnr(int rssnr) { + this.rssnr = rssnr; + return this; + } + + public CellSignalStrengthLteBuilder setCqiTableIndex(int cqiTableIndex) { + this.cqiTableIndex = cqiTableIndex; + return this; + } + + public CellSignalStrengthLteBuilder setCqi(int cqi) { + this.cqi = cqi; + return this; + } + + public CellSignalStrengthLteBuilder setTimingAdvance(int timingAdvance) { + this.timingAdvance = timingAdvance; + return this; + } + + public CellSignalStrengthLte build() { + CellSignalStrengthLteReflector cellSignalStrengthReflector = + reflector(CellSignalStrengthLteReflector.class); + if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.S) { + return cellSignalStrengthReflector.newCellSignalStrength( + rssi, rsrp, rsrq, rssnr, cqi, timingAdvance); + } else { + return cellSignalStrengthReflector.newCellSignalStrength( + rssi, rsrp, rsrq, rssnr, cqiTableIndex, cqi, timingAdvance); + } + } + + @ForType(CellSignalStrengthLte.class) + private interface CellSignalStrengthLteReflector { + @Constructor + CellSignalStrengthLte newCellSignalStrength(); + + @Constructor + CellSignalStrengthLte newCellSignalStrength( + int rssi, int rsrp, int rsrq, int rssnr, int cqi, int timingAdvance); + + @Constructor + CellSignalStrengthLte newCellSignalStrength( + int rssi, int rsrp, int rsrq, int rssnr, int cqiTableIndex, int cqi, int timingAdvance); + } +} diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java index 841f7d5f8..459340273 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/MediaCodecInfoBuilder.java @@ -10,6 +10,7 @@ import android.media.MediaCodecInfo.CodecProfileLevel; import android.media.MediaCodecInfo.EncoderCapabilities; import android.media.MediaCodecInfo.VideoCapabilities; import android.media.MediaFormat; +import android.util.Range; import com.google.common.base.Preconditions; import org.robolectric.RuntimeEnvironment; import org.robolectric.util.ReflectionHelpers; @@ -266,6 +267,17 @@ public class MediaCodecInfoBuilder { void setFlagsSupported(int flagsSupported); } + /** Accessor interface for {@link VideoCapabilities}'s internals. */ + @ForType(VideoCapabilities.class) + interface VideoCapabilitiesReflector { + + @Accessor("mWidthRange") + void setWidthRange(Range<Integer> range); + + @Accessor("mHeightRange") + void setHeightRange(Range<Integer> range); + } + public CodecCapabilities build() { Preconditions.checkNotNull(mediaFormat, "mediaFormat is not set."); Preconditions.checkNotNull(profileLevels, "profileLevels is not set."); @@ -298,6 +310,16 @@ public class MediaCodecInfoBuilder { if (isVideoCodec) { VideoCapabilities videoCaps = createDefaultVideoCapabilities(caps, mediaFormat); + VideoCapabilitiesReflector videoCapsReflector = + Reflector.reflector(VideoCapabilitiesReflector.class, videoCaps); + if (mediaFormat.containsKey(MediaFormat.KEY_WIDTH)) { + videoCapsReflector.setWidthRange( + new Range<>(1, mediaFormat.getInteger(MediaFormat.KEY_WIDTH))); + } + if (mediaFormat.containsKey(MediaFormat.KEY_HEIGHT)) { + videoCapsReflector.setHeightRange( + new Range<>(1, mediaFormat.getInteger(MediaFormat.KEY_HEIGHT))); + } capsReflector.setVideoCaps(videoCaps); } else { AudioCapabilities audioCaps = createDefaultAudioCapabilities(caps, mediaFormat); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java index 5da1409f3..d23045b24 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ResourceModeShadowPicker.java @@ -1,6 +1,7 @@ package org.robolectric.shadows; import android.os.Build; +import android.os.Build.VERSION_CODES; import org.robolectric.RuntimeEnvironment; import org.robolectric.shadow.api.ShadowPicker; @@ -10,6 +11,7 @@ public class ResourceModeShadowPicker<T> implements ShadowPicker<T> { private Class<? extends T> binaryShadowClass; private Class<? extends T> binary9ShadowClass; private Class<? extends T> binary10ShadowClass; + private Class<? extends T> binary14ShadowClass; public ResourceModeShadowPicker(Class<? extends T> legacyShadowClass, Class<? extends T> binaryShadowClass, @@ -18,16 +20,19 @@ public class ResourceModeShadowPicker<T> implements ShadowPicker<T> { this.binaryShadowClass = binaryShadowClass; this.binary9ShadowClass = binary9ShadowClass; this.binary10ShadowClass = binary9ShadowClass; + this.binary14ShadowClass = binary9ShadowClass; } public ResourceModeShadowPicker(Class<? extends T> legacyShadowClass, Class<? extends T> binaryShadowClass, Class<? extends T> binary9ShadowClass, - Class<? extends T> binary10ShadowClass) { + Class<? extends T> binary10ShadowClass, + Class<? extends T> binary14ShadowClass) { this.legacyShadowClass = legacyShadowClass; this.binaryShadowClass = binaryShadowClass; this.binary9ShadowClass = binary9ShadowClass; this.binary10ShadowClass = binary10ShadowClass; + this.binary14ShadowClass = binary14ShadowClass; } @Override @@ -35,10 +40,11 @@ public class ResourceModeShadowPicker<T> implements ShadowPicker<T> { if (ShadowAssetManager.useLegacy()) { return legacyShadowClass; } else { - if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) { + if (RuntimeEnvironment.getApiLevel() > VERSION_CODES.TIRAMISU) { + return binary14ShadowClass; + } else if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) { return binary10ShadowClass; - } else - if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.P) { + } else if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.P) { return binary9ShadowClass; } else { return binaryShadowClass; diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java index 47f7306a4..1d575a4b9 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivity.java @@ -426,10 +426,10 @@ public class ShadowActivity extends ShadowContextThemeWrapper { @Implementation protected void runOnUiThread(Runnable action) { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - reflector(DirectActivityReflector.class, realActivity).runOnUiThread(action); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowApplication.getInstance().getForegroundThreadScheduler().post(action); + } else { + reflector(DirectActivityReflector.class, realActivity).runOnUiThread(action); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java index 883dd2cad..70464bf9c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowActivityThread.java @@ -26,7 +26,6 @@ import java.lang.reflect.Proxy; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import javax.annotation.Nonnull; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Implementation; @@ -34,6 +33,7 @@ import org.robolectric.annotation.Implements; import org.robolectric.annotation.RealObject; import org.robolectric.annotation.ReflectorObject; import org.robolectric.annotation.Resetter; +import org.robolectric.util.Logger; import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.reflector.Accessor; import org.robolectric.util.reflector.ForType; @@ -275,7 +275,12 @@ public class ShadowActivityThread { @Resetter public static void reset() { Object activityThread = RuntimeEnvironment.getActivityThread(); - Objects.requireNonNull(activityThread, "ShadowActivityThread.reset: ActivityThread not set"); - reflector(_ActivityThread_.class, activityThread).getActivities().clear(); + if (activityThread == null) { + Logger.warn( + "RuntimeEnvironment.getActivityThread() is null, an error likely occurred during test" + + " initialization."); + } else { + reflector(_ActivityThread_.class, activityThread).getActivities().clear(); + } } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager14.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager14.java new file mode 100644 index 000000000..8771d6adb --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager14.java @@ -0,0 +1,72 @@ +package org.robolectric.shadows; + + +import android.annotation.Nullable; +import android.content.res.AssetManager; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +// TODO: update path to released version. +// transliterated from +// https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_rXX/core/jni/android_util_AssetManager.cpp + +@Implements( + value = AssetManager.class, + minSdk = ShadowBuild.UPSIDE_DOWN_CAKE, + shadowPicker = ShadowAssetManager.Picker.class) +@SuppressWarnings("NewApi") +public class ShadowArscAssetManager14 extends ShadowArscAssetManager10 { + + // static void NativeSetConfiguration(JNIEnv* env, jclass /*clazz*/, jlong ptr, jint mcc, jint + // mnc, + // jstring locale, jint orientation, jint touchscreen, jint + // density, + // jint keyboard, jint keyboard_hidden, jint navigation, + // jint screen_width, jint screen_height, + // jint smallest_screen_width_dp, jint screen_width_dp, + // jint screen_height_dp, jint screen_layout, jint ui_mode, + // jint color_mode, jint major_version) { + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected static void nativeSetConfiguration( + long ptr, + int mcc, + int mnc, + @Nullable String locale, + int orientation, + int touchscreen, + int density, + int keyboard, + int keyboard_hidden, + int navigation, + int screen_width, + int screen_height, + int smallest_screen_width_dp, + int screen_width_dp, + int screen_height_dp, + int screen_layout, + int ui_mode, + int color_mode, + int grammaticalGender, // ignore for now? + int major_version) { + ShadowArscAssetManager10.nativeSetConfiguration( + ptr, + mcc, + mnc, + locale, + orientation, + touchscreen, + density, + keyboard, + keyboard_hidden, + navigation, + screen_width, + screen_height, + smallest_screen_width_dp, + screen_width_dp, + screen_height_dp, + screen_layout, + ui_mode, + color_mode, + major_version); + } +} +// namespace android diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java index 1f6e40ddf..19c5196f0 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAssetManager.java @@ -25,7 +25,8 @@ abstract public class ShadowAssetManager { ShadowLegacyAssetManager.class, ShadowArscAssetManager.class, ShadowArscAssetManager9.class, - ShadowArscAssetManager10.class); + ShadowArscAssetManager10.class, + ShadowArscAssetManager14.class); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java index 5b051405f..c1e78be00 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioSystem.java @@ -3,10 +3,23 @@ package org.robolectric.shadows; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; +import static com.google.common.base.Preconditions.checkNotNull; +import android.annotation.NonNull; +import android.media.AudioAttributes; +import android.media.AudioFormat; import android.media.AudioSystem; +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.google.common.collect.Table; +import com.google.common.collect.Tables; +import java.util.Optional; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Resetter; /** Shadow for {@link AudioSystem}. */ @Implements(value = AudioSystem.class, isInAndroidSdk = false) @@ -17,6 +30,33 @@ public class ShadowAudioSystem { private static final int MAX_SAMPLE_RATE = 192000; private static final int MIN_SAMPLE_RATE = 4000; + /** + * Table to store key-pair of {@link AudioFormat} and {@link AudioAttributes#getUsage()} with + * value of support for Direct Playback. Used with {@link #setDirectPlaybackSupport(AudioFormat, + * AudioAttributes, int)}, and {@link #getDirectPlaybackSupport(AudioFormat, AudioAttributes)}. + */ + private static final Table<AudioFormat, Integer, Integer> directPlaybackSupportTable = + Tables.synchronizedTable(HashBasedTable.create()); + /** + * Table to store pair of {@link OffloadSupportFormat} and {@link + * AudioAttributes#getVolumeControlStream()} with a value of Offload Playback support. Used with + * {@link #native_get_offload_support}. The table uses {@link OffloadSupportFormat} rather than + * {@link AudioFormat} because {@link #native_get_offload_support} does not pass all the fields + * needed to reliably reconstruct {@link AudioFormat} instances. + */ + private static final Table<OffloadSupportFormat, Integer, Integer> offloadPlaybackSupportTable = + Tables.synchronizedTable(HashBasedTable.create()); + + /** + * Multimap to store whether a pair of {@link OffloadSupportFormat} and {@link + * AudioAttributes#getVolumeControlStream()} ()} support offloaded playback. Used with {@link + * #native_is_offload_supported}. The map uses {@link OffloadSupportFormat} keys rather than + * {@link AudioFormat} because {@link #native_is_offload_supported} does not pass all the fields + * needed to reliably reconstruct {@link AudioFormat} instances. + */ + private static final Multimap<OffloadSupportFormat, Integer> offloadSupportedMap = + Multimaps.synchronizedMultimap(HashMultimap.create()); + @Implementation(minSdk = S) protected static int native_getMaxChannelCount() { return MAX_CHANNEL_COUNT; @@ -38,4 +78,156 @@ public class ShadowAudioSystem { // https://cs.android.com/android/platform/superproject/+/master:system/media/audio/include/system/audio-base.h;l=197;drc=c84ca89fa5d660046364897482b202c797c8595e return 8; } + + /** + * Sets direct playback support for a key-pair of {@link AudioFormat} and {@link AudioAttributes}. + * As a result, calling {@link #getDirectPlaybackSupport} with the same pair of {@link + * AudioFormat} and {@link AudioAttributes} values will return the cached support value. + * + * @param format the audio format (codec, sample rate, channels) + * @param attr the {@link AudioAttributes} to be used for playback + * @param directPlaybackSupport the level of direct playback support to save for the format and + * attribute pair. Must be one of {@link AudioSystem#DIRECT_NOT_SUPPORTED}, {@link + * AudioSystem#OFFLOAD_NOT_SUPPORTED}, {@link AudioSystem#OFFLOAD_SUPPORTED}, {@link + * AudioSystem#OFFLOAD_GAPLESS_SUPPORTED}, or a combination of {@link + * AudioSystem#DIRECT_OFFLOAD_SUPPORTED}, {@link AudioSystem#DIRECT_OFFLOAD_GAPLESS_SUPPORTED} + * and {@link AudioSystem#DIRECT_BITSTREAM_SUPPORTED} + */ + public static void setDirectPlaybackSupport( + @NonNull AudioFormat format, @NonNull AudioAttributes attr, int directPlaybackSupport) { + checkNotNull(format, "Illegal null AudioFormat"); + checkNotNull(attr, "Illegal null AudioAttributes"); + directPlaybackSupportTable.put(format, attr.getUsage(), directPlaybackSupport); + } + + /** + * Retrieves the stored direct playback support for the {@link AudioFormat} and {@link + * AudioAttributes}. If no value was stored for the key-pair then {@link + * AudioSystem#DIRECT_NOT_SUPPORTED} is returned. + * + * @param format the audio format (codec, sample rate, channels) to be used for playback + * @param attr the {@link AudioAttributes} to be used for playback + * @return the level of direct playback playback support for the format and attributes. + */ + @Implementation(minSdk = TIRAMISU) + protected static int getDirectPlaybackSupport( + @NonNull AudioFormat format, @NonNull AudioAttributes attr) { + return Optional.ofNullable(directPlaybackSupportTable.get(format, attr.getUsage())) + .orElse(AudioSystem.DIRECT_NOT_SUPPORTED); + } + + /** + * Sets offload playback support for a key-pair of {@link AudioFormat} and {@link + * AudioAttributes}. As a result, calling {@link AudioSystem#getOffloadSupport} with the same pair + * of {@link AudioFormat} and {@link AudioAttributes} values will return the cached support value. + * + * @param format the audio format (codec, sample rate, channels) + * @param attr the {@link AudioAttributes} to be used for playback + * @param offloadSupport the level of offload playback support to save for the format and + * attribute pair. Must be one of {@link AudioSystem#OFFLOAD_NOT_SUPPORTED}, {@link + * AudioSystem#OFFLOAD_SUPPORTED} or {@link AudioSystem#OFFLOAD_GAPLESS_SUPPORTED}. + */ + public static void setOffloadPlaybackSupport( + @NonNull AudioFormat format, @NonNull AudioAttributes attr, int offloadSupport) { + checkNotNull(format, "Illegal null AudioFormat"); + checkNotNull(attr, "Illegal null AudioAttributes"); + offloadPlaybackSupportTable.put( + new OffloadSupportFormat( + format.getEncoding(), + format.getSampleRate(), + format.getChannelMask(), + format.getChannelIndexMask()), + attr.getVolumeControlStream(), + offloadSupport); + } + + /** + * Sets whether offload playback is supported for a key-pair of {@link AudioFormat} and {@link + * AudioAttributes}. As a result, calling {@link AudioSystem#isOffloadSupported} with the same + * pair of {@link AudioFormat} and {@link AudioAttributes} values will return {@code supported}. + * + * @param format the audio format (codec, sample rate, channels) + * @param attr the {@link AudioAttributes} to be used for playback + */ + public static void setOffloadSupported( + @NonNull AudioFormat format, @NonNull AudioAttributes attr, boolean supported) { + OffloadSupportFormat offloadSupportFormat = + new OffloadSupportFormat( + format.getEncoding(), + format.getSampleRate(), + format.getChannelMask(), + format.getChannelIndexMask()); + if (supported) { + offloadSupportedMap.put(offloadSupportFormat, attr.getVolumeControlStream()); + } else { + offloadSupportedMap.remove(offloadSupportFormat, attr.getVolumeControlStream()); + } + } + + @Implementation(minSdk = Q, maxSdk = R) + protected static boolean native_is_offload_supported( + int encoding, int sampleRate, int channelMask, int channelIndexMask, int streamType) { + return offloadSupportedMap.containsEntry( + new OffloadSupportFormat(encoding, sampleRate, channelMask, channelIndexMask), streamType); + } + + @Implementation(minSdk = S) + protected static int native_get_offload_support( + int encoding, int sampleRate, int channelMask, int channelIndexMask, int streamType) { + return Optional.ofNullable( + offloadPlaybackSupportTable.get( + new OffloadSupportFormat(encoding, sampleRate, channelMask, channelIndexMask), + streamType)) + .orElse(AudioSystem.OFFLOAD_NOT_SUPPORTED); + } + + @Resetter + public static void reset() { + directPlaybackSupportTable.clear(); + offloadPlaybackSupportTable.clear(); + offloadSupportedMap.clear(); + } + + /** + * Struct to hold specific values from {@link AudioFormat} which are used in {@link + * #native_get_offload_support} and {@link #native_is_offload_supported}. + */ + private static class OffloadSupportFormat { + public final int encoding; + public final int sampleRate; + public final int channelMask; + public final int channelIndexMask; + + public OffloadSupportFormat( + int encoding, int sampleRate, int channelMask, int channelIndexMask) { + this.encoding = encoding; + this.sampleRate = sampleRate; + this.channelMask = channelMask; + this.channelIndexMask = channelIndexMask; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OffloadSupportFormat)) { + return false; + } + OffloadSupportFormat that = (OffloadSupportFormat) o; + return encoding == that.encoding + && sampleRate == that.sampleRate + && channelMask == that.channelMask + && channelIndexMask == that.channelIndexMask; + } + + @Override + public int hashCode() { + int result = encoding; + result = 31 * result + sampleRate; + result = 31 * result + channelMask; + result = 31 * result + channelIndexMask; + return result; + } + } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java index 45a5557a6..6408ce87c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java @@ -1,20 +1,37 @@ package org.robolectric.shadows; import static android.media.AudioTrack.ERROR_BAD_VALUE; +import static android.media.AudioTrack.ERROR_DEAD_OBJECT; import static android.media.AudioTrack.WRITE_BLOCKING; import static android.media.AudioTrack.WRITE_NON_BLOCKING; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.N; import static android.os.Build.VERSION_CODES.P; +import static android.os.Build.VERSION_CODES.Q; +import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import android.annotation.NonNull; +import android.media.AudioAttributes; import android.media.AudioFormat; import android.media.AudioTrack; import android.media.AudioTrack.WriteMode; +import android.media.PlaybackParams; +import android.os.Build.VERSION; +import android.os.Parcel; import android.util.Log; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; @@ -50,11 +67,24 @@ public class ShadowAudioTrack { protected static final int DEFAULT_MIN_BUFFER_SIZE = 1024; + // Copied from native code + // https://cs.android.com/android/platform/superproject/+/android13-release:frameworks/base/core/jni/android_media_AudioTrack.cpp?q=AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED + private static final int AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED = -20; + private static final String TAG = "ShadowAudioTrack"; - private static int minBufferSize = DEFAULT_MIN_BUFFER_SIZE; + /** Direct playback support checked from {@link #native_is_direct_output_supported}. */ + private static final Multimap<AudioFormatInfo, AudioAttributesInfo> directSupportedFormats = + Multimaps.synchronizedMultimap(HashMultimap.create()); + /** Non-PCM encodings allowed for creating an AudioTrack instance. */ + private static final Set<Integer> allowedNonPcmEncodings = + Collections.synchronizedSet(new HashSet<>()); + private static final List<OnAudioDataWrittenListener> audioDataWrittenListeners = new CopyOnWriteArrayList<>(); + private static int minBufferSize = DEFAULT_MIN_BUFFER_SIZE; + private int numBytesReceived; + private PlaybackParams playbackParams; @RealObject AudioTrack audioTrack; /** @@ -67,6 +97,61 @@ public class ShadowAudioTrack { minBufferSize = bufferSize; } + /** + * Adds support for direct playback for the pair of {@link AudioFormat} and {@link + * AudioAttributes} where the format encoding must be non-PCM. Calling {@link + * AudioTrack#isDirectPlaybackSupported(AudioFormat, AudioAttributes)} will return {@code true} + * for matching {@link AudioFormat} and {@link AudioAttributes}. The matching is performed against + * the format's {@linkplain AudioFormat#getEncoding() encoding}, {@linkplain + * AudioFormat#getSampleRate() sample rate}, {@linkplain AudioFormat#getChannelMask() channel + * mask} and {@linkplain AudioFormat#getChannelIndexMask() channel index mask}, and the + * attribute's {@linkplain AudioAttributes#getContentType() content type}, {@linkplain + * AudioAttributes#getUsage() usage} and {@linkplain AudioAttributes#getFlags() flags}. + * + * @param format The {@link AudioFormat}, which must be of a non-PCM encoding. If the encoding is + * PCM, the method will throw an {@link IllegalArgumentException}. + * @param attr The {@link AudioAttributes}. + */ + public static void addDirectPlaybackSupport( + @NonNull AudioFormat format, @NonNull AudioAttributes attr) { + checkNotNull(format); + checkNotNull(attr); + checkArgument(!isPcm(format.getEncoding())); + + directSupportedFormats.put( + new AudioFormatInfo( + format.getEncoding(), + format.getSampleRate(), + format.getChannelMask(), + format.getChannelIndexMask()), + new AudioAttributesInfo(attr.getContentType(), attr.getUsage(), attr.getFlags())); + } + + /** + * Clears all encodings that have been added for direct playback support with {@link + * #addDirectPlaybackSupport}. + */ + public static void clearDirectPlaybackSupportedFormats() { + directSupportedFormats.clear(); + } + + /** + * Add a non-PCM encoding for which {@link AudioTrack} instances are allowed to be created. + * + * @param encoding One of {@link AudioFormat} {@code ENCODING_} constants that represents a + * non-PCM encoding. If {@code encoding} is PCM, this method throws an {@link + * IllegalArgumentException}. + */ + public static void addAllowedNonPcmEncoding(int encoding) { + checkArgument(!isPcm(encoding)); + allowedNonPcmEncodings.add(encoding); + } + + /** Clears all encodings that have been added with {@link #addAllowedNonPcmEncoding(int)}. */ + public static void clearAllowedNonPcmEncodings() { + allowedNonPcmEncodings.clear(); + } + @Implementation(minSdk = N, maxSdk = P) protected static int native_get_FCC_8() { // Return the value hard-coded in native code: @@ -74,6 +159,20 @@ public class ShadowAudioTrack { return 8; } + @Implementation(minSdk = Q) + protected static boolean native_is_direct_output_supported( + int encoding, + int sampleRate, + int channelMask, + int channelIndexMask, + int contentType, + int usage, + int flags) { + return directSupportedFormats.containsEntry( + new AudioFormatInfo(encoding, sampleRate, channelMask, channelIndexMask), + new AudioAttributesInfo(contentType, usage, flags)); + } + /** Returns a predefined or default minimum buffer size. Audio format and config are neglected. */ @Implementation protected static int native_get_min_buff_size( @@ -81,24 +180,141 @@ public class ShadowAudioTrack { return minBufferSize; } + @Implementation(minSdk = P, maxSdk = Q) + protected int native_setup( + Object /*WeakReference<AudioTrack>*/ audioTrack, + Object /*AudioAttributes*/ attributes, + int[] sampleRate, + int channelMask, + int channelIndexMask, + int audioFormat, + int buffSizeInBytes, + int mode, + int[] sessionId, + long nativeAudioTrack, + boolean offload) { + // If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem. + if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) { + return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED; + } + return AudioTrack.SUCCESS; + } + + @Implementation(minSdk = R, maxSdk = R) + protected int native_setup( + Object /*WeakReference<AudioTrack>*/ audioTrack, + Object /*AudioAttributes*/ attributes, + int[] sampleRate, + int channelMask, + int channelIndexMask, + int audioFormat, + int buffSizeInBytes, + int mode, + int[] sessionId, + long nativeAudioTrack, + boolean offload, + int encapsulationMode, + Object tunerConfiguration) { + // If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem. + if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) { + return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED; + } + return AudioTrack.SUCCESS; + } + + @Implementation(minSdk = S, maxSdk = TIRAMISU) + protected int native_setup( + Object /*WeakReference<AudioTrack>*/ audioTrack, + Object /*AudioAttributes*/ attributes, + int[] sampleRate, + int channelMask, + int channelIndexMask, + int audioFormat, + int buffSizeInBytes, + int mode, + int[] sessionId, + long nativeAudioTrack, + boolean offload, + int encapsulationMode, + Object tunerConfiguration, + String opPackageName) { + // If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem. + if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) { + return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED; + } + return AudioTrack.SUCCESS; + } + + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected int native_setup( + Object /*WeakReference<AudioTrack>*/ audioTrack, + Object /*AudioAttributes*/ attributes, + int[] sampleRate, + int channelMask, + int channelIndexMask, + int audioFormat, + int buffSizeInBytes, + int mode, + int[] sessionId, + @NonNull Parcel attributionSource, + long nativeAudioTrack, + boolean offload, + int encapsulationMode, + Object tunerConfiguration, + @NonNull String opPackageName) { + // If offload, AudioTrack.Builder.build() has checked offload support via AudioSystem. + if (!offload && !isPcm(audioFormat) && !allowedNonPcmEncodings.contains(audioFormat)) { + return AUDIOTRACK_ERROR_SETUP_NATIVEINITFAILED; + } + return AudioTrack.SUCCESS; + } + /** - * Always return the number of bytes to write. This method returns immedidately even with {@link - * AudioTrack#WRITE_BLOCKING} + * Returns the number of bytes to write. This method returns immediately even with {@link + * AudioTrack#WRITE_BLOCKING}. If the {@link AudioTrack} instance was created with a non-PCM + * encoding and the encoding can no longer be played directly, the method will return {@link + * AudioTrack#ERROR_DEAD_OBJECT}; */ @Implementation(minSdk = M) protected final int native_write_byte( byte[] audioData, int offsetInBytes, int sizeInBytes, int format, boolean isBlocking) { + int encoding = audioTrack.getAudioFormat(); + // Assume that offload support does not change during the lifetime of the instance. + if ((VERSION.SDK_INT < 29 || !audioTrack.isOffloadedPlayback()) + && !isPcm(encoding) + && !allowedNonPcmEncodings.contains(encoding)) { + return ERROR_DEAD_OBJECT; + } return sizeInBytes; } + @Implementation(minSdk = M) + public void setPlaybackParams(@NonNull PlaybackParams params) { + playbackParams = checkNotNull(params, "Illegal null params"); + } + + @Implementation(minSdk = M) + @NonNull + protected final PlaybackParams getPlaybackParams() { + return playbackParams; + } + /** - * Always return the number of bytes to write except with invalid parameters. Assumes AudioTrack - * is already initialized (object properly created). Do not block even if AudioTrack in offload - * mode is in STOPPING play state. This method returns immediately even with {@link - * AudioTrack#WRITE_BLOCKING} + * Returns the number of bytes to write, except with invalid parameters. If the {@link AudioTrack} + * was created for a non-PCM encoding that can no longer be played directly, it returns {@link + * AudioTrack#ERROR_DEAD_OBJECT}. Assumes {@link AudioTrack} is already initialized (object + * properly created). Do not block even if {@link AudioTrack} in offload mode is in STOPPING play + * state. This method returns immediately even with {@link AudioTrack#WRITE_BLOCKING} */ @Implementation(minSdk = LOLLIPOP) protected int write(@NonNull ByteBuffer audioData, int sizeInBytes, @WriteMode int writeMode) { + int encoding = audioTrack.getAudioFormat(); + // Assume that offload support does not change during the lifetime of the instance. + if ((VERSION.SDK_INT < 29 || !audioTrack.isOffloadedPlayback()) + && !isPcm(encoding) + && !allowedNonPcmEncodings.contains(encoding)) { + return ERROR_DEAD_OBJECT; + } if (writeMode != WRITE_BLOCKING && writeMode != WRITE_NON_BLOCKING) { Log.e(TAG, "ShadowAudioTrack.write() called with invalid blocking mode"); return ERROR_BAD_VALUE; @@ -150,5 +366,103 @@ public class ShadowAudioTrack { @Resetter public static void resetTest() { audioDataWrittenListeners.clear(); + clearDirectPlaybackSupportedFormats(); + clearAllowedNonPcmEncodings(); + } + + private static boolean isPcm(int encoding) { + switch (encoding) { + case AudioFormat.ENCODING_PCM_8BIT: + case AudioFormat.ENCODING_PCM_16BIT: + case AudioFormat.ENCODING_PCM_24BIT_PACKED: + case AudioFormat.ENCODING_PCM_32BIT: + case AudioFormat.ENCODING_PCM_FLOAT: + return true; + default: + return false; + } + } + + /** + * Specific fields from {@link AudioFormat} that are used for detection of direct playback + * support. + * + * @see #native_is_direct_output_supported + */ + private static class AudioFormatInfo { + private final int encoding; + private final int sampleRate; + private final int channelMask; + private final int channelIndexMask; + + public AudioFormatInfo(int encoding, int sampleRate, int channelMask, int channelIndexMask) { + this.encoding = encoding; + this.sampleRate = sampleRate; + this.channelMask = channelMask; + this.channelIndexMask = channelIndexMask; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AudioFormatInfo)) { + return false; + } + + AudioFormatInfo other = (AudioFormatInfo) o; + return encoding == other.encoding + && sampleRate == other.sampleRate + && channelMask == other.channelMask + && channelIndexMask == other.channelIndexMask; + } + + @Override + public int hashCode() { + int result = encoding; + result = 31 * result + sampleRate; + result = 31 * result + channelMask; + result = 31 * result + channelIndexMask; + return result; + } + } + + /** + * Specific fields from {@link AudioAttributes} used for detection of direct playback support. + * + * @see #native_is_direct_output_supported + */ + private static class AudioAttributesInfo { + private final int contentType; + private final int usage; + private final int flags; + + public AudioAttributesInfo(int contentType, int usage, int flags) { + this.contentType = contentType; + this.usage = usage; + this.flags = flags; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AudioAttributesInfo)) { + return false; + } + + AudioAttributesInfo other = (AudioAttributesInfo) o; + return contentType == other.contentType && usage == other.usage && flags == other.flags; + } + + @Override + public int hashCode() { + int result = contentType; + result = 31 * result + usage; + result = 31 * result + flags; + return result; + } } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java index 737df1fb8..7b6ff7e37 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothGatt.java @@ -18,6 +18,8 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; @@ -40,8 +42,12 @@ public class ShadowBluetoothGatt { private boolean isClosed = false; private byte[] writtenBytes; private byte[] readBytes; + // TODO: ShadowBluetoothGatt.services should be removed in favor of just using the real + // BluetoothGatt.mServices. private final Set<BluetoothGattService> discoverableServices = new HashSet<>(); private final ArrayList<BluetoothGattService> services = new ArrayList<>(); + private final Set<BluetoothGattCharacteristic> characteristicNotificationEnableSet = + new HashSet<>(); @RealObject private BluetoothGatt realBluetoothGatt; @ReflectorObject protected BluetoothGattReflector bluetoothGattReflector; @@ -185,6 +191,7 @@ public class ShadowBluetoothGatt { protected boolean discoverServices() { this.services.clear(); if (!this.discoverableServices.isEmpty()) { + // TODO: Don't store the services in the shadow. this.services.addAll(this.discoverableServices); if (this.getGattCallback() != null) { @@ -204,10 +211,39 @@ public class ShadowBluetoothGatt { */ @Implementation(minSdk = O) protected List<BluetoothGattService> getServices() { + // TODO: Remove this method when real BluetoothGatt#getServices() works. return new ArrayList<>(this.services); } /** + * Overrides {@link BluetoothGatt#getService} to return a service with given UUID. + * + * @return a service with given UUID that have been discovered through {@link + * ShadowBluetoothGatt#discoverServices}. + */ + @Implementation(minSdk = O) + @Nullable + protected BluetoothGattService getService(UUID uuid) { + // TODO: Remove this method when real BluetoothGatt#getService() works. + for (BluetoothGattService service : this.services) { + if (service.getUuid().equals(uuid)) { + return service; + } + } + return null; + } + + /** + * Overrides {@link BluetoothGatt#setCharacteristicNotification} so it returns true (false) if + * allowCharacteristicNotification (disallowCharacteristicNotification) is called. + */ + @Implementation(minSdk = O) + protected boolean setCharacteristicNotification( + BluetoothGattCharacteristic characteristic, boolean enable) { + return characteristicNotificationEnableSet.contains(characteristic) == enable; + } + + /** * Reads bytes from incoming characteristic if properties are valid and callback is set. Callback * responds with {@link BluetoothGattCallback#onCharacteristicWrite} and returns true when * successful. @@ -258,6 +294,16 @@ public class ShadowBluetoothGatt { return true; } + /** Allows the incoming characteristic to be set to enable notification. */ + public void allowCharacteristicNotification(BluetoothGattCharacteristic characteristic) { + characteristicNotificationEnableSet.add(characteristic); + } + + /** Disallows the incoming characteristic to be set to enable notification. */ + public void disallowCharacteristicNotification(BluetoothGattCharacteristic characteristic) { + characteristicNotificationEnableSet.remove(characteristic); + } + public void addDiscoverableService(BluetoothGattService service) { this.discoverableServices.add(service); } @@ -294,6 +340,49 @@ public class ShadowBluetoothGatt { return this.readBytes; } + public BluetoothConnectionManager getBluetoothConnectionManager() { + return BluetoothConnectionManager.getInstance(); + } + + /** + * Simulate a successful Gatt Client Conection with {@link BluetoothConnectionManager}. Performs a + * {@link BluetoothGattCallback#onConnectionStateChange} if available. + * + * @param remoteAddress address of Gatt client + */ + public void notifyConnection(String remoteAddress) { + BluetoothConnectionManager.getInstance().registerGattClientConnection(remoteAddress); + this.isConnected = true; + if (this.isCallbackAppropriate()) { + this.getGattCallback() + .onConnectionStateChange( + this.realBluetoothGatt, BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED); + } + } + + /** + * Simulate a successful Gatt Client Disconnection with {@link BluetoothConnectionManager}. + * Performs a {@link BluetoothGattCallback#onConnectionStateChange} if available. + * + * @param remoteAddress address of Gatt client + */ + public void notifyDisconnection(String remoteAddress) { + BluetoothConnectionManager.getInstance().unregisterGattClientConnection(remoteAddress); + if (this.isCallbackAppropriate()) { + this.getGattCallback() + .onConnectionStateChange( + this.realBluetoothGatt, + BluetoothGatt.GATT_SUCCESS, + BluetoothProfile.STATE_DISCONNECTED); + } + this.isConnected = false; + } + + private boolean isCallbackAppropriate() { + return this.getGattCallback() != null && this.isConnected; + } + + @ForType(BluetoothGatt.class) private interface BluetoothGattReflector { diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java index 54b96265b..f13928859 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothHeadset.java @@ -3,13 +3,17 @@ package org.robolectric.shadows; import static android.os.Build.VERSION_CODES.KITKAT; import static android.os.Build.VERSION_CODES.P; import static android.os.Build.VERSION_CODES.S; +import static java.util.stream.Collectors.toCollection; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothHeadset; import android.bluetooth.BluetoothProfile; import android.content.Intent; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Objects; import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; @@ -21,7 +25,8 @@ import org.robolectric.annotation.Implements; @NotThreadSafe @Implements(value = BluetoothHeadset.class) public class ShadowBluetoothHeadset { - private final List<BluetoothDevice> connectedDevices = new ArrayList<>(); + + private final Map<BluetoothDevice, Integer> bluetoothDevices = new HashMap<>(); private boolean allowsSendVendorSpecificResultCode = true; private BluetoothDevice activeBluetoothDevice; private boolean isVoiceRecognitionSupported = true; @@ -32,12 +37,29 @@ public class ShadowBluetoothHeadset { */ @Implementation protected List<BluetoothDevice> getConnectedDevices() { - return connectedDevices; + return bluetoothDevices.entrySet().stream() + .filter(entry -> entry.getValue() == BluetoothProfile.STATE_CONNECTED) + .map(Entry::getKey) + .collect(toCollection(ArrayList::new)); } /** Adds the given BluetoothDevice to the shadow's list of "connected devices" */ public void addConnectedDevice(BluetoothDevice device) { - connectedDevices.add(device); + addDevice(device, BluetoothProfile.STATE_CONNECTED); + } + + /** + * Adds the provided BluetoothDevice to the shadow profile's device list with an associated + * connectionState. The provided connection state will be returned by {@link + * ShadowBluetoothHeadset#getConnectionState}. + */ + public void addDevice(BluetoothDevice bluetoothDevice, int connectionState) { + bluetoothDevices.put(bluetoothDevice, connectionState); + } + + /** Remove the given BluetoothDevice from the shadow profile's device list */ + public void removeDevice(BluetoothDevice bluetoothDevice) { + bluetoothDevices.remove(bluetoothDevice); } /** @@ -49,9 +71,7 @@ public class ShadowBluetoothHeadset { */ @Implementation protected int getConnectionState(BluetoothDevice device) { - return connectedDevices.contains(device) - ? BluetoothProfile.STATE_CONNECTED - : BluetoothProfile.STATE_DISCONNECTED; + return bluetoothDevices.getOrDefault(device, BluetoothProfile.STATE_DISCONNECTED); } /** @@ -63,7 +83,7 @@ public class ShadowBluetoothHeadset { */ @Implementation protected boolean startVoiceRecognition(BluetoothDevice bluetoothDevice) { - if (bluetoothDevice == null || !connectedDevices.contains(bluetoothDevice)) { + if (bluetoothDevice == null || !getConnectedDevices().contains(bluetoothDevice)) { return false; } if (activeBluetoothDevice != null) { @@ -113,7 +133,7 @@ public class ShadowBluetoothHeadset { if (command == null) { throw new IllegalArgumentException("Command cannot be null"); } - return allowsSendVendorSpecificResultCode && connectedDevices.contains(device); + return allowsSendVendorSpecificResultCode && getConnectedDevices().contains(device); } @Nullable diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java index b0cc137fe..c1c887acc 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBuild.java @@ -23,6 +23,12 @@ public class ShadowBuild { private static String serialOverride = Build.UNKNOWN; /** + * Temporary constant for VERSION_CODES.UPSIDE_DOWN_CAKE. Will be removed and replaced once the + * constant is available upstream. + */ + public static final int UPSIDE_DOWN_CAKE = 34; + + /** * Sets the value of the {@link Build#DEVICE} field. * * <p>It will be reset for the next test. diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java index 55b9b68c6..19be93acc 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCameraManager.java @@ -77,7 +77,18 @@ public class ShadowCameraManager { cameraTorches.put(cameraId, enabled); } - @Implementation(minSdk = Build.VERSION_CODES.S) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected CameraDevice openCameraDeviceUserAsync( + String cameraId, + CameraDevice.StateCallback callback, + Executor executor, + final int uid, + final int oomScoreOffset, + boolean overrideToPortrait) { + return openCameraDeviceUserAsync(cameraId, callback, executor, uid, oomScoreOffset); + } + + @Implementation(minSdk = Build.VERSION_CODES.S, maxSdk = Build.VERSION_CODES.TIRAMISU) protected CameraDevice openCameraDeviceUserAsync( String cameraId, CameraDevice.StateCallback callback, diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java index cf1aac20e..a4b1fb79f 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowChoreographer.java @@ -54,13 +54,13 @@ public abstract class ShadowChoreographer { * <p>Only works in {@link LooperMode.Mode#PAUSED} looper mode. */ public static void setFrameDelay(Duration delay) { - checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED); + checkState(!ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper cannot be %s", Mode.LEGACY); frameDelay = delay; } /** See {@link #setFrameDelay(Duration)}. */ public static Duration getFrameDelay() { - checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED); + checkState(!ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper cannot be %s", Mode.LEGACY); return frameDelay; } @@ -72,13 +72,13 @@ public abstract class ShadowChoreographer { * <p>Only works in {@link LooperMode.Mode#PAUSED} looper mode. */ public static void setPaused(boolean paused) { - checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED); + checkState(!ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper cannot be %s", Mode.LEGACY); isPaused = paused; } /** See {@link #setPaused(boolean)}. */ public static boolean isPaused() { - checkState(ShadowLooper.looperMode().equals(Mode.PAUSED), "Looper must be %s", Mode.PAUSED); + checkState(!ShadowLooper.looperMode().equals(Mode.LEGACY), "Looper cannot be %s", Mode.LEGACY); return isPaused; } @@ -109,11 +109,11 @@ public abstract class ShadowChoreographer { */ @Deprecated public static void setPostFrameCallbackDelay(int delayMillis) { - if (looperMode() == Mode.PAUSED) { + if (looperMode() == Mode.LEGACY) { + ShadowLegacyChoreographer.setPostFrameCallbackDelay(delayMillis); + } else { setPaused(delayMillis != 0); setFrameDelay(Duration.ofMillis(delayMillis == 0 ? 1 : delayMillis)); - } else { - ShadowLegacyChoreographer.setPostFrameCallbackDelay(delayMillis); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java index 933024646..00bb9558c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayEventReceiver.java @@ -12,6 +12,7 @@ import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; import static android.os.Build.VERSION_CODES.S; import static android.os.Build.VERSION_CODES.TIRAMISU; +import static org.robolectric.util.reflector.Reflector.reflector; import android.os.MessageQueue; import android.os.SystemClock; @@ -29,9 +30,8 @@ import org.robolectric.annotation.RealObject; import org.robolectric.annotation.ReflectorObject; import org.robolectric.res.android.NativeObjRegistry; import org.robolectric.shadow.api.Shadow; -import org.robolectric.util.ReflectionHelpers; -import org.robolectric.util.ReflectionHelpers.ClassParameter; import org.robolectric.util.reflector.Accessor; +import org.robolectric.util.reflector.Constructor; import org.robolectric.util.reflector.Direct; import org.robolectric.util.reflector.ForType; import org.robolectric.util.reflector.WithType; @@ -86,7 +86,7 @@ public class ShadowDisplayEventReceiver { new NativeDisplayEventReceiver(new WeakReference<>((DisplayEventReceiver) receiver))); } - @Implementation(minSdk = R) + @Implementation(minSdk = R, maxSdk = TIRAMISU) protected static long nativeInit( WeakReference<DisplayEventReceiver> receiver, MessageQueue msgQueue, @@ -95,7 +95,18 @@ public class ShadowDisplayEventReceiver { return nativeInit(receiver, msgQueue); } - @Implementation(minSdk = KITKAT_WATCH) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected static long nativeInit( + WeakReference<DisplayEventReceiver> receiver, + WeakReference<Object> vsyncEventData, + MessageQueue msgQueue, + int vsyncSource, + int eventRegistration, + long layerHandle) { + return nativeInit(receiver, msgQueue); + } + + @Implementation(minSdk = KITKAT_WATCH, maxSdk = TIRAMISU) protected static void nativeDispose(long receiverPtr) { NativeDisplayEventReceiver receiver = nativeObjRegistry.unregister(receiverPtr); if (receiver != null) { @@ -141,24 +152,11 @@ public class ShadowDisplayEventReceiver { displayEventReceiverReflector.onVsync( ShadowSystem.nanoTime(), 0L, /* SurfaceControl.BUILT_IN_DISPLAY_ID_MAIN */ 1); } else if (RuntimeEnvironment.getApiLevel() < TIRAMISU) { - try { - // onVsync takes a package-private VSyncData class as a parameter, thus reflection - // needs to be used - Object vsyncData = - ReflectionHelpers.callConstructor( - Class.forName("android.view.DisplayEventReceiver$VsyncEventData"), - ClassParameter.from(long.class, 1), /* id */ - ClassParameter.from(long.class, 10), /* frameDeadline */ - ClassParameter.from(long.class, 1)); /* frameInterval */ - - displayEventReceiverReflector.onVsync( - ShadowSystem.nanoTime(), - 0L, /* physicalDisplayId currently ignored */ - /* frame= */ 1, - vsyncData /* VsyncEventData */); - } catch (ClassNotFoundException e) { - throw new LinkageError("Unable to construct VsyncEventData", e); - } + displayEventReceiverReflector.onVsync( + ShadowSystem.nanoTime(), + 0L, /* physicalDisplayId currently ignored */ + /* frame= */ 1, + newVsyncEventData() /* VsyncEventData */); } else { displayEventReceiverReflector.onVsync( ShadowSystem.nanoTime(), @@ -240,6 +238,11 @@ public class ShadowDisplayEventReceiver { } private static Object /* VsyncEventData */ newVsyncEventData() { + VsyncEventDataReflector vsyncEventDataReflector = reflector(VsyncEventDataReflector.class); + if (RuntimeEnvironment.getApiLevel() < TIRAMISU) { + return vsyncEventDataReflector.newVsyncEventData( + /* id= */ 1, /* frameDeadline= */ 10, /* frameInterval= */ 1); + } try { // onVsync on T takes a package-private VsyncEventData class, which is itself composed of a // package private VsyncEventData.FrameTimeline class. So use reflection to build these up @@ -247,33 +250,26 @@ public class ShadowDisplayEventReceiver { Class.forName("android.view.DisplayEventReceiver$VsyncEventData$FrameTimeline"); int timelineArrayLength = RuntimeEnvironment.getApiLevel() == TIRAMISU ? 1 : 7; - + FrameTimelineReflector frameTimelineReflector = reflector(FrameTimelineReflector.class); Object timelineArray = Array.newInstance(frameTimelineClass, timelineArrayLength); for (int i = 0; i < timelineArrayLength; i++) { - Array.set(timelineArray, i, newFrameTimeline(frameTimelineClass)); + Array.set(timelineArray, i, frameTimelineReflector.newFrameTimeline(1, 1, 10)); + } + if (RuntimeEnvironment.getApiLevel() <= TIRAMISU) { + return vsyncEventDataReflector.newVsyncEventData( + timelineArray, /* preferredFrameTimelineIndex= */ 0, /* frameInterval= */ 1); + } else { + return vsyncEventDataReflector.newVsyncEventData( + timelineArray, + /* preferredFrameTimelineIndex= */ 0, + timelineArrayLength, + /* frameInterval= */ 1); } - - // get FrameTimeline[].class - Class<?> frameTimeLineArrayClass = - Class.forName("[Landroid.view.DisplayEventReceiver$VsyncEventData$FrameTimeline;"); - return ReflectionHelpers.callConstructor( - Class.forName("android.view.DisplayEventReceiver$VsyncEventData"), - ClassParameter.from(frameTimeLineArrayClass, timelineArray), - ClassParameter.from(int.class, 0), /* frameDeadline */ - ClassParameter.from(long.class, 1)); /* frameInterval */ } catch (ClassNotFoundException e) { throw new LinkageError("Unable to construct VsyncEventData", e); } } - private static Object newFrameTimeline(Class<?> frameTimelineClass) { - return ReflectionHelpers.callConstructor( - frameTimelineClass, - ClassParameter.from(long.class, 1) /* vsync id */, - ClassParameter.from(long.class, 1) /* expectedPresentTime */, - ClassParameter.from(long.class, 10) /* deadline */); - } - /** Reflector interface for {@link DisplayEventReceiver}'s internals. */ @ForType(DisplayEventReceiver.class) protected interface DisplayEventReceiverReflector { @@ -295,5 +291,35 @@ public class ShadowDisplayEventReceiver { @Accessor("mCloseGuard") CloseGuard getCloseGuard(); + + @Accessor("mReceiverPtr") + long getReceiverPtr(); + } + + @ForType(className = "android.view.DisplayEventReceiver$VsyncEventData") + interface VsyncEventDataReflector { + @Constructor + Object newVsyncEventData(long id, long frameDeadline, long frameInterval); + + @Constructor + Object newVsyncEventData( + @WithType("[Landroid.view.DisplayEventReceiver$VsyncEventData$FrameTimeline;") + Object frameTimelineArray, + int preferredFrameTimelineIndex, + long frameInterval); + + @Constructor + Object newVsyncEventData( + @WithType("[Landroid.view.DisplayEventReceiver$VsyncEventData$FrameTimeline;") + Object frameTimelineArray, + int preferredFrameTimelineIndex, + int timelineArrayLength, + long frameInterval); + } + + @ForType(className = "android.view.DisplayEventReceiver$VsyncEventData$FrameTimeline") + interface FrameTimelineReflector { + @Constructor + Object newFrameTimeline(long id, long expectedPresentTime, long deadline); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java index 9f7957303..f7f5f5b8c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowDisplayManagerGlobal.java @@ -1,6 +1,5 @@ package org.robolectric.shadows; -import static android.os.Build.VERSION_CODES.CUR_DEVELOPMENT; import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; import static android.os.Build.VERSION_CODES.O_MR1; import static android.os.Build.VERSION_CODES.P; @@ -88,20 +87,26 @@ public class ShadowDisplayManagerGlobal { reflector(DisplayManagerGlobalReflector.class, instance); displayManagerGlobal.setDm(displayManager); displayManagerGlobal.setLock(new Object()); + List<Handler> displayListeners = createDisplayListeners(); + displayManagerGlobal.setDisplayListeners(displayListeners); + displayManagerGlobal.setDisplayInfoCache(new SparseArray<>()); + return instance; + } - List displayListeners = new CopyOnWriteArrayList(); + private static List<Handler> createDisplayListeners() { try { - // TODO: rexhoffman when we have sufficient detection in android dev replace - // this with a version check. + // The type for mDisplayListeners was changed from ArrayList to CopyOnWriteArrayList + // in some branches of T and U, so we need to reflect on DisplayManagerGlobal class + // to check the type of mDisplayListeners member before initializing appropriately. Field f = DisplayManagerGlobal.class.getDeclaredField("mDisplayListeners"); if (f.getType().isAssignableFrom(ArrayList.class)) { - displayListeners = new ArrayList(); + return new ArrayList<>(); + } else { + return new CopyOnWriteArrayList<>(); } } catch (NoSuchFieldException e) { + throw new RuntimeException(e); } - displayManagerGlobal.setDisplayListeners(displayListeners); - displayManagerGlobal.setDisplayInfoCache(new SparseArray<>()); - return instance; } @VisibleForTesting diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java index 791ece2ec..58cd55818 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageDecoder.java @@ -10,6 +10,7 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.ColorSpace; +import android.graphics.ColorSpace.Named; import android.graphics.ImageDecoder; import android.graphics.ImageDecoder.DecodeException; import android.graphics.ImageDecoder.Source; @@ -247,14 +248,16 @@ public class ShadowImageDecoder { static String ImageDecoder_nGetMimeType(long nativePtr) { CppImageDecoder decoder = NATIVE_IMAGE_DECODER_REGISTRY.getNativeObject(nativePtr); // return encodedFormatToString(decoder.mCodec.getEncodedFormat()); - throw new UnsupportedOperationException(); + // TODO: fix this properly. Just hardcode to png for now or just remove GraphicsMode.LEGACY + return "image/png"; } static ColorSpace ImageDecoder_nGetColorSpace(long nativePtr) { // auto colorType = codec.computeOutputColorType(codec.getInfo().colorType()); // sk_sp<SkColorSpace> colorSpace = codec.computeOutputColorSpace(colorType); // return GraphicsJNI.getColorSpace(colorSpace, colorType); - throw new UnsupportedOperationException(); + // TODO: fix this properly. Just hardcode to SRGB for now or just remove GraphicsMode.LEGACY + return ColorSpace.get(Named.SRGB); } // native method implementations... diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java index 0654fbc4f..b8564ca40 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowImageReader.java @@ -1,6 +1,5 @@ package org.robolectric.shadows; -import static android.os.Build.VERSION_CODES.CUR_DEVELOPMENT; import static android.os.Build.VERSION_CODES.KITKAT; import static android.os.Build.VERSION_CODES.S_V2; import static android.os.Build.VERSION_CODES.TIRAMISU; @@ -70,7 +69,7 @@ public class ShadowImageReader { return nativeImageSetup(image); } - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected int nativeImageSetup(Object /* Image */ image) { return nativeImageSetup((Image) image); } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java index 50635c8b2..298fabec6 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputManager.java @@ -1,10 +1,14 @@ package org.robolectric.shadows; +import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.KITKAT; import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.robolectric.util.reflector.Reflector.reflector; import android.hardware.input.InputManager; +import android.util.SparseArray; import android.view.InputDevice; import android.view.InputEvent; import android.view.KeyEvent; @@ -13,13 +17,18 @@ import android.view.VerifiedKeyEvent; import android.view.VerifiedMotionEvent; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; import org.robolectric.annotation.Resetter; import org.robolectric.util.ReflectionHelpers; +import org.robolectric.util.reflector.Accessor; +import org.robolectric.util.reflector.ForType; /** Shadow for {@link InputManager} */ @Implements(value = InputManager.class, looseSignatures = true) public class ShadowInputManager { + @RealObject InputManager realInputManager; + @Implementation protected boolean injectInputEvent(InputEvent event, int mode) { // ignore @@ -37,6 +46,35 @@ public class ShadowInputManager { return new int[0]; } + @Implementation(maxSdk = TIRAMISU) + protected void populateInputDevicesLocked() throws ClassNotFoundException { + if (ReflectionHelpers.getField(realInputManager, "mInputDevicesChangedListener") == null) { + ReflectionHelpers.setField( + realInputManager, + "mInputDevicesChangedListener", + ReflectionHelpers.callConstructor( + Class.forName("android.hardware.input.InputManager$InputDevicesChangedListener"))); + } + + if (getInputDevices() == null) { + final int[] ids = realInputManager.getInputDeviceIds(); + + SparseArray<InputDevice> inputDevices = new SparseArray<>(); + for (int i = 0; i < ids.length; i++) { + inputDevices.put(ids[i], null); + } + setInputDevices(inputDevices); + } + } + + private SparseArray<InputDevice> getInputDevices() { + return reflector(InputManagerReflector.class, realInputManager).getInputDevices(); + } + + private void setInputDevices(SparseArray<InputDevice> devices) { + reflector(InputManagerReflector.class, realInputManager).setInputDevices(devices); + } + /** * Provides a local java implementation, since the real implementation is in system server + * native code. @@ -78,6 +116,17 @@ public class ShadowInputManager { @Resetter public static void reset() { - ReflectionHelpers.setStaticField(InputManager.class, "sInstance", null); + if (SDK_INT < ShadowBuild.UPSIDE_DOWN_CAKE) { + ReflectionHelpers.setStaticField(InputManager.class, "sInstance", null); + } + } + + @ForType(InputManager.class) + interface InputManagerReflector { + @Accessor("mInputDevices") + SparseArray<InputDevice> getInputDevices(); + + @Accessor("mInputDevices") + void setInputDevices(SparseArray<InputDevice> devices); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java index 42baa4c1f..bf87b3a5b 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowJobService.java @@ -2,6 +2,7 @@ package org.robolectric.shadows; import static android.os.Build.VERSION_CODES.LOLLIPOP; +import android.app.Notification; import android.app.job.JobParameters; import android.app.job.JobService; import org.robolectric.annotation.Implementation; @@ -19,6 +20,14 @@ public class ShadowJobService extends ShadowService { this.isRescheduleNeeded = needsReschedule; } + /** Stubbed out for now, as the real implementation throws an NPE when executed in Robolectric. */ + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected void setNotification( + JobParameters params, + int notificationId, + Notification notification, + int jobEndNotificationPolicy) {} + /** * Returns whether the job has finished running. When using this shadow this returns true after * {@link #jobFinished(JobParameters, boolean)} is called. diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java index f624b60d5..2fb348ebd 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLegacyLooper.java @@ -58,7 +58,7 @@ public class ShadowLegacyLooper extends ShadowLooper { @Resetter public static synchronized void resetThreadLoopers() { // do not use looperMode() here, because its cached value might already have been reset - if (ConfigurationRegistry.get(LooperMode.Mode.class) == LooperMode.Mode.PAUSED) { + if (ConfigurationRegistry.get(LooperMode.Mode.class) != LooperMode.Mode.LEGACY) { // ignore if realistic looper return; } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java index 103907b93..9bef2d193 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaCodec.java @@ -1,6 +1,5 @@ package org.robolectric.shadows; -import static android.os.Build.VERSION_CODES.CUR_DEVELOPMENT; import static android.os.Build.VERSION_CODES.JELLY_BEAN; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.N_MR1; @@ -409,7 +408,7 @@ public class ShadowMediaCodec { @Implementation(minSdk = LOLLIPOP, maxSdk = TIRAMISU) protected void invalidateByteBuffer(@Nullable ByteBuffer[] buffers, int index) {} - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void invalidateByteBufferLocked( @Nullable ByteBuffer[] buffers, int index, boolean input) {} @@ -417,14 +416,14 @@ public class ShadowMediaCodec { @Implementation(minSdk = LOLLIPOP, maxSdk = TIRAMISU) protected void validateInputByteBuffer(@Nullable ByteBuffer[] buffers, int index) {} - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void validateInputByteBufferLocked(@Nullable ByteBuffer[] buffers, int index) {} /** Prevents calling Android-only methods on basic ByteBuffer objects. */ @Implementation(minSdk = LOLLIPOP, maxSdk = TIRAMISU) protected void revalidateByteBuffer(@Nullable ByteBuffer[] buffers, int index) {} - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void revalidateByteBuffer(@Nullable ByteBuffer[] buffers, int index, boolean input) {} /** @@ -442,7 +441,7 @@ public class ShadowMediaCodec { } } - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void validateOutputByteBufferLocked( @Nullable ByteBuffer[] buffers, int index, @NonNull BufferInfo info) { validateOutputByteBuffer(buffers, index, info); @@ -452,14 +451,14 @@ public class ShadowMediaCodec { @Implementation(minSdk = LOLLIPOP, maxSdk = TIRAMISU) protected void invalidateByteBuffers(@Nullable ByteBuffer[] buffers) {} - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void invalidateByteBuffersLocked(@Nullable ByteBuffer[] buffers) {} /** Prevents attempting to free non-direct ByteBuffer objects. */ @Implementation(minSdk = LOLLIPOP, maxSdk = TIRAMISU) protected void freeByteBuffer(@Nullable ByteBuffer buffer) {} - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected void freeByteBufferLocked(@Nullable ByteBuffer buffer) {} /** Shadows CodecBuffer to prevent attempting to free non-direct ByteBuffer objects. */ diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java index 587356009..f08a53c3f 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowMediaPlayer.java @@ -37,6 +37,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Random; import java.util.TreeMap; import org.robolectric.annotation.Implementation; @@ -112,8 +113,7 @@ public class ShadowMediaPlayer extends ShadowPlayerBase { private static final Map<DataSource, Exception> exceptions = new HashMap<>(); private static final Map<DataSource, MediaInfo> mediaInfoMap = new HashMap<>(); - private static final MediaInfoProvider DEFAULT_MEDIA_INFO_PROVIDER = mediaInfoMap::get; - private static MediaInfoProvider mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; + private static Optional<MediaInfoProvider> mediaInfoProvider = Optional.empty(); @RealObject private MediaPlayer player; @@ -650,7 +650,7 @@ public class ShadowMediaPlayer extends ShadowPlayerBase { * @see #setDataSource(DataSource) */ public void doSetDataSource(DataSource dataSource) { - MediaInfo mediaInfo = mediaInfoProvider.get(dataSource); + MediaInfo mediaInfo = getMediaInfo(dataSource); if (mediaInfo == null) { throw new IllegalArgumentException( "Don't know what to do with dataSource " @@ -663,17 +663,16 @@ public class ShadowMediaPlayer extends ShadowPlayerBase { } public static MediaInfo getMediaInfo(DataSource dataSource) { - return mediaInfoProvider.get(dataSource); + if (mediaInfoMap.containsKey(dataSource)) { + return mediaInfoMap.get(dataSource); + } + return mediaInfoProvider.map(provider -> provider.get(dataSource)).orElse(null); } /** * Adds a {@link MediaInfo} for a {@link DataSource}. - * - * <p>This overrides any {@link MediaInfoProvider} previously set by calling {@link - * #setMediaInfoProvider}, i.e., the provider will not be used for any {@link DataSource}. */ public static void addMediaInfo(DataSource dataSource, MediaInfo info) { - ShadowMediaPlayer.mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; mediaInfoMap.put(dataSource, info); } @@ -685,7 +684,7 @@ public class ShadowMediaPlayer extends ShadowPlayerBase { * {@link MediaInfo} provided by this {@link MediaInfoProvider} will be used instead. */ public static void setMediaInfoProvider(MediaInfoProvider mediaInfoProvider) { - ShadowMediaPlayer.mediaInfoProvider = mediaInfoProvider; + ShadowMediaPlayer.mediaInfoProvider = Optional.of(mediaInfoProvider); } public static void addException(DataSource dataSource, RuntimeException e) { @@ -1536,7 +1535,7 @@ public class ShadowMediaPlayer extends ShadowPlayerBase { @Resetter public static void resetStaticState() { createListener = null; - mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; + mediaInfoProvider = Optional.empty(); exceptions.clear(); mediaInfoMap.clear(); DataSource.reset(); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeFontsFontFamily.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeFontsFontFamily.java index 8b7b8fa86..7b045e836 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeFontsFontFamily.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativeFontsFontFamily.java @@ -1,6 +1,5 @@ package org.robolectric.shadows; -import static android.os.Build.VERSION_CODES.CUR_DEVELOPMENT; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.S; import static android.os.Build.VERSION_CODES.TIRAMISU; @@ -64,6 +63,12 @@ public class ShadowNativeFontsFontFamily { return FontFamilyBuilderNatives.nBuild(builderPtr, langTags, variant, isCustomFallback); } + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) + protected static long nBuild( + long builderPtr, String langTags, int variant, boolean isCustomFallback, boolean isDefaultFallback) { + return nBuild(builderPtr, langTags, variant, isCustomFallback); + } + @Implementation protected static long nGetReleaseNativeFamily() { return FontFamilyBuilderNatives.nGetReleaseNativeFamily(); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePaint.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePaint.java index 94fadb5ab..32d428088 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePaint.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNativePaint.java @@ -813,7 +813,7 @@ public class ShadowNativePaint { paintPtr, text, start, count, ctxStart, ctxCount, isRtl, outMetrics); } - @Implementation(minSdk = 10000) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected static float nGetRunCharacterAdvance( long paintPtr, char[] text, diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java index 0f9e44d17..4cdfb4532 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowNfcAdapter.java @@ -221,7 +221,7 @@ public class ShadowNfcAdapter { } if (RuntimeEnvironment.getApiLevel() >= Build.VERSION_CODES.Q) { nfcAdapterReflector.setHasNfcFeature(false); - if (RuntimeEnvironment.getApiLevel() < VERSION_CODES.CUR_DEVELOPMENT) { + if (RuntimeEnvironment.getApiLevel() <= VERSION_CODES.TIRAMISU) { nfcAdapterReflector.setHasBeamFeature(false); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java index 50f8adf62..ee3bef016 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java @@ -12,6 +12,7 @@ import android.os.Message; import android.os.MessageQueue.IdleHandler; import android.os.SystemClock; import android.util.Log; +import com.google.common.base.Preconditions; import java.time.Duration; import java.util.ArrayList; import java.util.Collection; @@ -58,6 +59,8 @@ public final class ShadowPausedLooper extends ShadowLooper { private static Set<Looper> loopingLoopers = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<Looper, Boolean>())); + private static boolean ignoreUncaughtExceptions = false; + @RealObject private Looper realLooper; private boolean isPaused = false; // the Executor that executes looper messages. Must be written to on looper thread @@ -317,6 +320,51 @@ public final class ShadowPausedLooper extends ShadowLooper { } /** + * By default Robolectric will put Loopers that throw uncaught exceptions in their loop method + * into an error state, where any future posting to the looper's queue will throw an error. + * + * <p>This API allows you to disable this behavior. Note this is a permanent setting - it is not + * reset between tests. + * + * @deprecated this method only exists to accommodate legacy tests with preexisting issues. + * Silently discarding exceptions is not recommended, and can lead to deadlocks. + */ + @Deprecated + public static void setIgnoreUncaughtExceptions(boolean shouldIgnore) { + ignoreUncaughtExceptions = shouldIgnore; + } + + /** + * Shadow loop to handle uncaught exceptions. Without this logic an uncaught exception on a looper + * thread will cause idle() to deadlock. + */ + @Implementation + protected static void loop() { + try { + reflector(LooperReflector.class).loop(); + } catch (Exception e) { + Looper realLooper = Preconditions.checkNotNull(Looper.myLooper()); + ShadowPausedMessageQueue shadowQueue = Shadow.extract(realLooper.getQueue()); + + if (ignoreUncaughtExceptions) { + // ignore + } else { + shadowQueue.setUncaughtException(e); + // release any ControlRunnables currently in queue to prevent deadlocks + shadowQueue.drainQueue( + input -> { + if (input instanceof ControlRunnable) { + ((ControlRunnable) input).runLatch.countDown(); + return true; + } + return false; + }); + } + throw e; + } + } + + /** * If the given {@code lastMessageRead} is not null and the queue is now idle, get the idle * handlers and run them. This synchronization mirrors what happens in the real message queue * next() method, but does not block after running the idle handlers. @@ -345,21 +393,40 @@ public final class ShadowPausedLooper extends ShadowLooper { private abstract static class ControlRunnable implements Runnable { protected final CountDownLatch runLatch = new CountDownLatch(1); + private volatile RuntimeException exception; - public void waitTillComplete() { + @Override + public void run() { + try { + doRun(); + } catch (RuntimeException e) { + if (!ignoreUncaughtExceptions) { + exception = e; + } + throw e; + } finally { + runLatch.countDown(); + } + } + + protected abstract void doRun() throws RuntimeException; + + public void waitTillComplete() throws RuntimeException { try { runLatch.await(); } catch (InterruptedException e) { Log.w("ShadowPausedLooper", "wait till idle interrupted"); } + if (exception != null) { + throw exception; + } } } private class IdlingRunnable extends ControlRunnable { @Override - public void run() { - try { + public void doRun() { while (true) { Message msg = getNextExecutableMessage(); if (msg == null) { @@ -369,26 +436,20 @@ public final class ShadowPausedLooper extends ShadowLooper { shadowMsg(msg).recycleUnchecked(); triggerIdleHandlersIfNeeded(msg); } - } finally { - runLatch.countDown(); - } } } private class RunOneRunnable extends ControlRunnable { @Override - public void run() { - try { + public void doRun() { + Message msg = shadowQueue().getNextIgnoringWhen(); if (msg != null) { SystemClock.setCurrentTimeMillis(shadowMsg(msg).getWhen()); msg.getTarget().dispatchMessage(msg); triggerIdleHandlersIfNeeded(msg); } - } finally { - runLatch.countDown(); - } } } @@ -408,6 +469,8 @@ public final class ShadowPausedLooper extends ShadowLooper { } looperExecutor.execute(runnable); runnable.waitTillComplete(); + // throw immediately if looper died while executing tasks + shadowQueue().checkQueueState(); } } @@ -422,6 +485,7 @@ public final class ShadowPausedLooper extends ShadowLooper { @Override public void execute(Runnable runnable) { + shadowQueue().checkQueueState(); executionQueue.add(runnable); } @@ -435,18 +499,22 @@ public final class ShadowPausedLooper extends ShadowLooper { Runnable runnable = executionQueue.take(); runnable.run(); } catch (InterruptedException e) { - // ignore + // ignored } } } + + @Override + protected void doRun() throws RuntimeException { + throw new UnsupportedOperationException(); + } } private class UnPauseRunnable extends ControlRunnable { @Override - public void run() { + public void doRun() { setLooperExecutor(new HandlerExecutor(new Handler(realLooper))); isPaused = false; - runLatch.countDown(); } } @@ -478,5 +546,8 @@ public final class ShadowPausedLooper extends ShadowLooper { @Direct void quitSafely(); + + @Direct + void loop(); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java index 162330aad..416033b9b 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java @@ -16,6 +16,9 @@ import android.os.Message; import android.os.MessageQueue; import android.os.MessageQueue.IdleHandler; import android.os.SystemClock; +import android.util.Log; +import androidx.annotation.VisibleForTesting; +import com.google.common.base.Predicate; import java.time.Duration; import java.util.ArrayList; import org.robolectric.RuntimeEnvironment; @@ -47,6 +50,7 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { new NativeObjRegistry<ShadowPausedMessageQueue>(ShadowPausedMessageQueue.class); private boolean isPolling = false; private ShadowPausedSystemClock.Listener clockListener; + private Exception uncaughtException = null; // shadow constructor instead of nativeInit because nativeInit signature has changed across SDK // versions @@ -210,8 +214,28 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { return reflector(MessageQueueReflector.class, realQueue).getQuitAllowed(); } + @VisibleForTesting void doEnqueueMessage(Message msg, long when) { - reflector(MessageQueueReflector.class, realQueue).enqueueMessage(msg, when); + enqueueMessage(msg, when); + } + + @Implementation + protected boolean enqueueMessage(Message msg, long when) { + synchronized (realQueue) { + if (uncaughtException != null) { + // looper thread has died + IllegalStateException e = + new IllegalStateException( + msg.getTarget() + + " sending message to a Looper thread that has died due to an uncaught" + + " exception", + uncaughtException); + Log.w("ShadowPausedMessageQueue", e); + msg.recycle(); + throw e; + } + return reflector(MessageQueueReflector.class, realQueue).enqueueMessage(msg, when); + } } Message getMessages() { @@ -340,6 +364,7 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { msgQueue.setMessages(null); msgQueue.setIdleHandlers(new ArrayList<>()); msgQueue.setNextBarrierToken(0); + setUncaughtException(null); } private static ShadowPausedMessage shadowOfMsg(Message head) { @@ -378,10 +403,50 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { } } + /** + * Called when an uncaught exception occurred in this message queue's Looper thread. + * + * <p>In real android, by default an exception handler is installed which kills the entire process + * when an uncaught exception occurs. We don't want to do this in robolectric to isolate tests, so + * instead an uncaught exception puts the message queue into an error state, where any future + * interaction will rethrow the exception. + */ + void setUncaughtException(Exception e) { + synchronized (realQueue) { + this.uncaughtException = e; + } + } + + void checkQueueState() { + synchronized (realQueue) { + if (uncaughtException != null) { + throw new IllegalStateException( + "Looper thread that has died due to an uncaught exception", uncaughtException); + } + } + } + + /** + * Remove all messages from queue + * + * @param msgProcessor a callback to apply to each mesg + */ + void drainQueue(Predicate<Runnable> msgProcessor) { + synchronized (realQueue) { + Message msg = getMessages(); + while (msg != null) { + boolean unused = msgProcessor.apply(msg.getCallback()); + ShadowMessage shadowMsg = Shadow.extract(msg); + msg.recycle(); + msg = shadowMsg.getNext(); + } + } + } + /** Accessor interface for {@link MessageQueue}'s internals. */ @ForType(MessageQueue.class) private interface MessageQueueReflector { - + @Direct boolean enqueueMessage(Message msg, long when); Message next(); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java index 839e28595..cbf52ae39 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java @@ -41,6 +41,7 @@ import android.content.integrity.IAppIntegrityManager; import android.content.pm.ICrossProfileApps; import android.content.pm.IShortcutService; import android.content.rollback.IRollbackManager; +import android.hardware.ISensorPrivacyManager; import android.hardware.biometrics.IAuthService; import android.hardware.biometrics.IBiometricService; import android.hardware.fingerprint.IFingerprintService; @@ -57,6 +58,7 @@ import android.net.IIpSecService; import android.net.INetworkPolicyManager; import android.net.INetworkScoreService; import android.net.ITetheringConnector; +import android.net.IVpnManager; import android.net.nsd.INsdManager; import android.net.vcn.IVcnManagementService; import android.net.wifi.IWifiManager; @@ -205,6 +207,8 @@ public class ShadowServiceManager { addBinderService(Context.UWB_SERVICE, IUwbAdapter.class); addBinderService(Context.VCN_MANAGEMENT_SERVICE, IVcnManagementService.class); addBinderService(Context.TRANSLATION_MANAGER_SERVICE, ITranslationManager.class); + addBinderService(Context.SENSOR_PRIVACY_SERVICE, ISensorPrivacyManager.class); + addBinderService(Context.VPN_MANAGEMENT_SERVICE, IVpnManager.class); } if (RuntimeEnvironment.getApiLevel() >= TIRAMISU) { addBinderService(Context.AMBIENT_CONTEXT_SERVICE, IAmbientContextManager.class); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java index a1895ff6b..c40d24e96 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSoundPool.java @@ -1,6 +1,5 @@ package org.robolectric.shadows; -import static android.os.Build.VERSION_CODES.CUR_DEVELOPMENT; import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1; import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.N; @@ -63,7 +62,7 @@ public class ShadowSoundPool { return 1; } - @Implementation(minSdk = CUR_DEVELOPMENT) + @Implementation(minSdk = ShadowBuild.UPSIDE_DOWN_CAKE) protected int _play( int soundID, float leftVolume, diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java index 529aaa405..fb5c1c295 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java @@ -405,6 +405,17 @@ public class ShadowSubscriptionManager { return phoneNumberMap.getOrDefault(subscriptionId, ""); } + /** + * Returns the phone number for the given {@code subscriptionId}, or an empty string if not + * available. {@code source} is ignored and will return the same as {@link #getPhoneNumber(int)}. + * + * <p>The phone number can be set by {@link #setPhoneNumber(int, String)} + */ + @Implementation(minSdk = TIRAMISU) + protected String getPhoneNumber(int subscriptionId, int source) { + return getPhoneNumber(subscriptionId); + } + /** Sets the phone number returned by {@link #getPhoneNumber(int)}. */ public void setPhoneNumber(int subscriptionId, String phoneNumber) { phoneNumberMap.put(subscriptionId, phoneNumber); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java index bc8528781..63d12e6a1 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSurfaceControl.java @@ -11,6 +11,7 @@ import android.view.SurfaceControl; import android.view.SurfaceSession; import dalvik.system.CloseGuard; import java.util.concurrent.atomic.AtomicInteger; +import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.annotation.ReflectorObject; @@ -82,6 +83,13 @@ public class ShadowSurfaceControl { void initializeNativeObject() { surfaceControlReflector.setNativeObject(nativeObject.incrementAndGet()); + if (RuntimeEnvironment.getApiLevel() >= ShadowBuild.UPSIDE_DOWN_CAKE) { + try { + surfaceControlReflector.setFreeNativeResources(() -> {}); + } catch(Exception e) { + // tm branches not yet have mFreeNativeResources added while in partial U state + } + } } @ForType(SurfaceControl.class) @@ -94,5 +102,8 @@ public class ShadowSurfaceControl { @Direct void finalize(); + + @Accessor("mFreeNativeResources") + void setFreeNativeResources(Runnable runnable); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java index a2bb38aba..b9013704a 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystem.java @@ -13,10 +13,10 @@ public class ShadowSystem { */ @SuppressWarnings("unused") public static long nanoTime() { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - return TimeUnit.MILLISECONDS.toNanos(SystemClock.uptimeMillis()); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { return ShadowLegacySystemClock.nanoTime(); + } else { + return TimeUnit.MILLISECONDS.toNanos(SystemClock.uptimeMillis()); } } @@ -27,10 +27,10 @@ public class ShadowSystem { */ @SuppressWarnings("unused") public static long currentTimeMillis() { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - return SystemClock.uptimeMillis(); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { return ShadowLegacySystemClock.currentTimeMillis(); + } else { + return SystemClock.uptimeMillis(); } } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java index 0864354d9..0ab373054 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java @@ -63,9 +63,11 @@ import com.google.common.collect.Iterables; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.HiddenApi; @@ -141,6 +143,7 @@ public class ShadowTelephonyManager { private String visualVoicemailPackageName = null; private SignalStrength signalStrength; private boolean dataEnabled = false; + private final Set<Integer> dataDisabledReasons = new HashSet<>(); private boolean isRttSupported; private final List<String> sentDialerSpecialCodes = new ArrayList<>(); private boolean hearingAidCompatibilitySupported = false; @@ -263,6 +266,13 @@ public class ShadowTelephonyManager { } /** Call state may be specified via {@link #setCallState(int)}. */ + @Implementation(minSdk = S) + protected int getCallStateForSubscription() { + checkReadPhoneStatePermission(); + return callState; + } + + /** Call state may be specified via {@link #setCallState(int)}. */ @Implementation protected int getCallState() { checkReadPhoneStatePermission(); @@ -1215,12 +1225,39 @@ public class ShadowTelephonyManager { } /** + * Implementation for {@link TelephonyManager#isDataEnabledForReason}. + * + * @return True by default, unless reason is set to false with {@link + * TelephonyManager#setDataEnabledForReason}. + */ + @Implementation(minSdk = Build.VERSION_CODES.S) + protected boolean isDataEnabledForReason(@TelephonyManager.DataEnabledReason int reason) { + checkReadPhoneStatePermission(); + return !dataDisabledReasons.contains(reason); + } + + /** * Implementation for {@link TelephonyManager#setDataEnabled}. Marked as public in order to allow * it to be used as a test API. */ @Implementation(minSdk = Build.VERSION_CODES.O) public void setDataEnabled(boolean enabled) { - dataEnabled = enabled; + setDataEnabledForReason(TelephonyManager.DATA_ENABLED_REASON_USER, enabled); + } + + /** + * Implementation for {@link TelephonyManager#setDataEnabledForReason}. Marked as public in order + * to allow it to be used as a test API. + */ + @Implementation(minSdk = Build.VERSION_CODES.S) + public void setDataEnabledForReason( + @TelephonyManager.DataEnabledReason int reason, boolean enabled) { + if (enabled) { + dataDisabledReasons.remove(reason); + } else { + dataDisabledReasons.add(reason); + } + dataEnabled = dataDisabledReasons.isEmpty(); } /** diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java index 5c8de7314..00ad65840 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowUserManager.java @@ -600,6 +600,16 @@ public class ShadowUserManager { } @HiddenApi + @Implementation(minSdk = R) + protected List<UserHandle> getUserHandles(boolean excludeDying) { + ArrayList<UserHandle> userHandles = new ArrayList<>(); + for (int id : userManagerState.userSerialNumbers.keySet()) { + userHandles.addAll(userManagerState.userProfilesListMap.get(id)); + } + return userHandles; + } + + @HiddenApi @Implementation(minSdk = JELLY_BEAN_MR1) protected static int getMaxSupportedUsers() { return maxSupportedUsers; @@ -998,6 +1008,9 @@ public class ShadowUserManager { @Implementation(minSdk = JELLY_BEAN_MR1) protected boolean removeUser(int userHandle) { + if (!userManagerState.userInfoMap.containsKey(userHandle)) { + return false; + } userManagerState.userInfoMap.remove(userHandle); userManagerState.userPidMap.remove(userHandle); userManagerState.userSerialNumbers.remove(userHandle); @@ -1021,6 +1034,13 @@ public class ShadowUserManager { return removeUser(user.getIdentifier()); } + @Implementation(minSdk = TIRAMISU) + protected int removeUserWhenPossible(UserHandle user, boolean overrideDevicePolicy) { + return removeUser(user.getIdentifier()) + ? UserManager.REMOVE_RESULT_REMOVED + : UserManager.REMOVE_RESULT_ERROR_UNKNOWN; + } + @Implementation(minSdk = N) protected static boolean supportsMultipleUsers() { return isMultiUserSupported; diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java index 70cb36999..5d06f587e 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowView.java @@ -524,31 +524,29 @@ public class ShadowView { @Implementation protected boolean post(Runnable action) { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - return reflector(_View_.class, realView).post(action); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowApplication.getInstance().getForegroundThreadScheduler().post(action); return true; + } else { + return reflector(_View_.class, realView).post(action); } } @Implementation protected boolean postDelayed(Runnable action, long delayMills) { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - return reflector(_View_.class, realView).postDelayed(action, delayMills); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowApplication.getInstance() .getForegroundThreadScheduler() .postDelayed(action, delayMills); return true; + } else { + return reflector(_View_.class, realView).postDelayed(action, delayMills); } } @Implementation protected void postInvalidateDelayed(long delayMilliseconds) { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - reflector(_View_.class, realView).postInvalidateDelayed(delayMilliseconds); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowApplication.getInstance() .getForegroundThreadScheduler() .postDelayed( @@ -559,17 +557,19 @@ public class ShadowView { } }, delayMilliseconds); + } else { + reflector(_View_.class, realView).postInvalidateDelayed(delayMilliseconds); } } @Implementation protected boolean removeCallbacks(Runnable callback) { - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - return reflector(_View_.class, realView).removeCallbacks(callback); - } else { + if (ShadowLooper.looperMode() == LooperMode.Mode.LEGACY) { ShadowLegacyLooper shadowLooper = Shadow.extract(Looper.getMainLooper()); shadowLooper.getScheduler().remove(callback); return true; + } else { + return reflector(_View_.class, realView).removeCallbacks(callback); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java index 28e668067..2f13fcf7d 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowViewGroup.java @@ -9,7 +9,7 @@ import android.view.ViewGroup; import java.io.PrintStream; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; -import org.robolectric.annotation.LooperMode; +import org.robolectric.annotation.LooperMode.Mode; import org.robolectric.annotation.RealObject; import org.robolectric.shadow.api.Shadow; import org.robolectric.util.reflector.Direct; @@ -29,10 +29,10 @@ public class ShadowViewGroup extends ShadowView { () -> { reflector(ViewGroupReflector.class, realViewGroup).addView(child, index, params); }; - if (ShadowLooper.looperMode() == LooperMode.Mode.PAUSED) { - addViewRunnable.run(); - } else { + if (ShadowLooper.looperMode() == Mode.LEGACY) { shadowMainLooper().runPaused(addViewRunnable); + } else { + addViewRunnable.run(); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnManager.java new file mode 100644 index 000000000..99f807b07 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVpnManager.java @@ -0,0 +1,67 @@ +package org.robolectric.shadows; + +import android.content.Intent; +import android.net.PlatformVpnProfile; +import android.net.VpnManager; +import android.net.VpnProfileState; +import android.os.Build.VERSION_CODES; +import java.util.UUID; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; + +/** Shadow for {@link VpnManager}. */ +@Implements(value = VpnManager.class, minSdk = VERSION_CODES.R) +public class ShadowVpnManager { + + private VpnProfileState vpnProfileState; + private Intent provisionVpnProfileIntent; + + @Implementation + protected void deleteProvisionedVpnProfile() { + vpnProfileState = null; + } + + @Implementation(minSdk = VERSION_CODES.TIRAMISU) + protected VpnProfileState getProvisionedVpnProfileState() { + return vpnProfileState; + } + + /** + * @see #setProvisionVpnProfileResult(Intent). + */ + @Implementation + protected Intent provisionVpnProfile(PlatformVpnProfile profile) { + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.TIRAMISU) { + vpnProfileState = new VpnProfileState(VpnProfileState.STATE_DISCONNECTED, null, false, false); + } + return provisionVpnProfileIntent; + } + + /** Sets the return value of #provisionVpnProfile(PlatformVpnProfile). */ + public void setProvisionVpnProfileResult(Intent intent) { + provisionVpnProfileIntent = intent; + } + + @Implementation + protected void startProvisionedVpnProfile() { + startProvisionedVpnProfileSession(); + } + + @Implementation(minSdk = VERSION_CODES.TIRAMISU) + protected String startProvisionedVpnProfileSession() { + String sessionKey = UUID.randomUUID().toString(); + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.TIRAMISU) { + vpnProfileState = + new VpnProfileState(VpnProfileState.STATE_CONNECTED, sessionKey, false, false); + } + return sessionKey; + } + + @Implementation + protected void stopProvisionedVpnProfile() { + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.TIRAMISU) { + vpnProfileState = new VpnProfileState(VpnProfileState.STATE_DISCONNECTED, null, false, false); + } + } +} diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java index 8e933d93e..7221e69e7 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWifiManager.java @@ -5,6 +5,9 @@ import static android.os.Build.VERSION_CODES.KITKAT; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.Q; import static android.os.Build.VERSION_CODES.R; +import static android.os.Build.VERSION_CODES.S; +import static android.os.Build.VERSION_CODES.TIRAMISU; +import static java.util.stream.Collectors.toList; import android.content.Context; import android.content.Intent; @@ -17,14 +20,19 @@ import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.MulticastLock; +import android.net.wifi.WifiSsid; import android.net.wifi.WifiUsabilityStatsEntry; +import android.os.Binder; import android.os.Handler; import android.os.Looper; import android.provider.Settings; import android.util.ArraySet; import android.util.Pair; import com.google.common.collect.ImmutableList; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.BitSet; import java.util.HashSet; import java.util.LinkedHashMap; @@ -71,6 +79,8 @@ public class ShadowWifiManager { @RealObject WifiManager wifiManager; private WifiConfiguration apConfig; private SoftApConfiguration softApConfig; + private final Object pnoRequestLock = new Object(); + private PnoScanRequest outstandingPnoScanRequest = null; @Implementation protected boolean setWifiEnabled(boolean wifiEnabled) { @@ -657,4 +667,176 @@ public class ShadowWifiManager { this.predictionHorizonSec = predictionHorizonSec; } } + + /** Informs the {@link WifiManager} of a list of PNO {@link ScanResult}. */ + public void networksFoundFromPnoScan(List<ScanResult> scanResults) { + synchronized (pnoRequestLock) { + List<ScanResult> scanResultsCopy = List.copyOf(scanResults); + if (outstandingPnoScanRequest == null + || outstandingPnoScanRequest.ssids.stream() + .noneMatch( + ssid -> + scanResultsCopy.stream() + .anyMatch(scanResult -> scanResult.getWifiSsid().equals(ssid)))) { + return; + } + Executor executor = outstandingPnoScanRequest.executor; + InternalPnoScanResultsCallback callback = outstandingPnoScanRequest.callback; + executor.execute(() -> callback.onScanResultsAvailable(scanResultsCopy)); + Intent intent = createPnoScanResultsBroadcastIntent(); + getContext().sendBroadcast(intent); + executor.execute( + () -> + callback.onRemoved( + InternalPnoScanResultsCallback.REMOVE_PNO_CALLBACK_RESULTS_DELIVERED)); + outstandingPnoScanRequest = null; + } + } + + // Object needs to be used here since PnoScanResultsCallback is hidden. The looseSignatures spec + // requires that all args are of type Object. + @Implementation(minSdk = TIRAMISU) + @HiddenApi + protected void setExternalPnoScanRequest( + Object ssids, Object frequencies, Object executor, Object callback) { + synchronized (pnoRequestLock) { + if (callback == null) { + throw new IllegalArgumentException("callback cannot be null"); + } + + List<WifiSsid> pnoSsids = (List<WifiSsid>) ssids; + int[] pnoFrequencies = (int[]) frequencies; + Executor pnoExecutor = (Executor) executor; + InternalPnoScanResultsCallback pnoCallback = new InternalPnoScanResultsCallback(callback); + + if (pnoExecutor == null) { + throw new IllegalArgumentException("executor cannot be null"); + } + if (pnoSsids == null || pnoSsids.isEmpty()) { + // The real WifiServiceImpl throws an IllegalStateException in this case, so keeping it the + // same for consistency. + throw new IllegalStateException("Ssids can't be null or empty"); + } + if (pnoSsids.size() > 2) { + throw new IllegalArgumentException("Ssid list can't be greater than 2"); + } + if (pnoFrequencies != null && pnoFrequencies.length > 10) { + throw new IllegalArgumentException("Length of frequencies must be smaller than 10"); + } + int uid = Binder.getCallingUid(); + String packageName = getContext().getPackageName(); + + if (outstandingPnoScanRequest != null) { + pnoExecutor.execute( + () -> + pnoCallback.onRegisterFailed( + uid == outstandingPnoScanRequest.uid + ? InternalPnoScanResultsCallback.REGISTER_PNO_CALLBACK_ALREADY_REGISTERED + : InternalPnoScanResultsCallback.REGISTER_PNO_CALLBACK_RESOURCE_BUSY)); + return; + } + + outstandingPnoScanRequest = + new PnoScanRequest(pnoSsids, pnoFrequencies, pnoExecutor, pnoCallback, packageName, uid); + pnoExecutor.execute(pnoCallback::onRegisterSuccess); + } + } + + @Implementation(minSdk = TIRAMISU) + @HiddenApi + protected void clearExternalPnoScanRequest() { + synchronized (pnoRequestLock) { + if (outstandingPnoScanRequest != null + && outstandingPnoScanRequest.uid == Binder.getCallingUid()) { + InternalPnoScanResultsCallback callback = outstandingPnoScanRequest.callback; + outstandingPnoScanRequest.executor.execute( + () -> + callback.onRemoved( + InternalPnoScanResultsCallback.REMOVE_PNO_CALLBACK_UNREGISTERED)); + outstandingPnoScanRequest = null; + } + } + } + + private static class PnoScanRequest { + private final List<WifiSsid> ssids; + private final List<Integer> frequencies; + private final Executor executor; + private final InternalPnoScanResultsCallback callback; + private final String packageName; + private final int uid; + + private PnoScanRequest( + List<WifiSsid> ssids, + int[] frequencies, + Executor executor, + InternalPnoScanResultsCallback callback, + String packageName, + int uid) { + this.ssids = List.copyOf(ssids); + this.frequencies = + frequencies == null ? List.of() : Arrays.stream(frequencies).boxed().collect(toList()); + this.executor = executor; + this.callback = callback; + this.packageName = packageName; + this.uid = uid; + } + } + + private Intent createPnoScanResultsBroadcastIntent() { + Intent intent = new Intent(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION); + intent.putExtra(WifiManager.EXTRA_RESULTS_UPDATED, true); + intent.setPackage(outstandingPnoScanRequest.packageName); + return intent; + } + + private static class InternalPnoScanResultsCallback { + static final int REGISTER_PNO_CALLBACK_ALREADY_REGISTERED = 1; + static final int REGISTER_PNO_CALLBACK_RESOURCE_BUSY = 2; + static final int REMOVE_PNO_CALLBACK_RESULTS_DELIVERED = 1; + static final int REMOVE_PNO_CALLBACK_UNREGISTERED = 2; + + final Object callback; + final Method availableCallback; + final Method successCallback; + final Method failedCallback; + final Method removedCallback; + + InternalPnoScanResultsCallback(Object callback) { + this.callback = callback; + try { + Class<?> pnoCallbackClass = callback.getClass(); + availableCallback = pnoCallbackClass.getMethod("onScanResultsAvailable", List.class); + successCallback = pnoCallbackClass.getMethod("onRegisterSuccess"); + failedCallback = pnoCallbackClass.getMethod("onRegisterFailed", int.class); + removedCallback = pnoCallbackClass.getMethod("onRemoved", int.class); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("callback is not of type PnoScanResultsCallback", e); + } + } + + void onScanResultsAvailable(List<ScanResult> scanResults) { + invokeCallback(availableCallback, scanResults); + } + + void onRegisterSuccess() { + invokeCallback(successCallback); + } + + void onRegisterFailed(int reason) { + invokeCallback(failedCallback, reason); + } + + void onRemoved(int reason) { + invokeCallback(removedCallback, reason); + } + + void invokeCallback(Method method, Object... args) { + try { + method.invoke(callback, args); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException("Failed to invoke " + method.getName(), e); + } + } + } } diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Constructor.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Constructor.java new file mode 100644 index 000000000..d69c39145 --- /dev/null +++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Constructor.java @@ -0,0 +1,11 @@ +package org.robolectric.util.reflector; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Indicates that the annotated method is a constructor. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Constructor {} diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java index 2873e2864..12f855f2b 100644 --- a/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java +++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/Reflector.java @@ -38,6 +38,8 @@ public class Reflector { private static final boolean DEBUG = false; private static final AtomicInteger COUNTER = new AtomicInteger(); private static final Map<Class<?>, Constructor<?>> cache = new ConcurrentHashMap<>(); + private static final Map<Class<?>, Object> staticReflectorCache = new ConcurrentHashMap<>(); + /** * Returns an object which provides accessors for invoking otherwise inaccessible static methods * and fields. @@ -56,6 +58,10 @@ public class Reflector { * @param target the target object */ public static <T> T reflector(Class<T> iClass, Object target) { + if (target == null && staticReflectorCache.containsKey(iClass)) { + return (T) staticReflectorCache.get(iClass); + } + Class<?> targetClass = determineTargetClass(iClass); Constructor<? extends T> ctor = (Constructor<? extends T>) cache.get(iClass); @@ -68,11 +74,15 @@ public class Reflector { () -> Reflector.<T>createReflectorClass(iClass, targetClass)); ctor = reflectorClass.getConstructor(targetClass); ctor.setAccessible(true); + cache.put(iClass, ctor); } - cache.put(iClass, ctor); + T instance = ctor.newInstance(target); + if (target == null) { + staticReflectorCache.put(iClass, instance); + } + return instance; - return ctor.newInstance(target); } catch (NoSuchMethodException | InstantiationException | IllegalAccessException diff --git a/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java b/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java index d3e366855..ea9b45c6d 100644 --- a/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java +++ b/utils/reflector/src/main/java/org/robolectric/util/reflector/ReflectorClassWriter.java @@ -15,6 +15,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.HashSet; import java.util.Set; +import javax.annotation.Nullable; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; @@ -29,6 +30,8 @@ class ReflectorClassWriter extends ClassWriter { private static final Type CLASS_TYPE = Type.getType(Class.class); private static final Type FIELD_TYPE = Type.getType(Field.class); private static final Type METHOD_TYPE = Type.getType(Method.class); + private static final Type CONSTRUCTOR_TYPE = Type.getType(java.lang.reflect.Constructor.class); + private static final Type STRING_TYPE = Type.getType(String.class); private static final Type STRINGBUILDER_TYPE = Type.getType(StringBuilder.class); @@ -45,6 +48,8 @@ class ReflectorClassWriter extends ClassWriter { findMethod(Class.class, "getDeclaredField", new Class<?>[] {String.class}); private static final org.objectweb.asm.commons.Method CLASS$GET_DECLARED_METHOD = findMethod(Class.class, "getDeclaredMethod", new Class<?>[] {String.class, Class[].class}); + private static final org.objectweb.asm.commons.Method CLASS$GET_DECLARED_CONSTRUCTOR = + findMethod(Class.class, "getDeclaredConstructor", new Class<?>[] {Class[].class}); private static final org.objectweb.asm.commons.Method ACCESSIBLE_OBJECT$SET_ACCESSIBLE = findMethod(AccessibleObject.class, "setAccessible", new Class<?>[] {boolean.class}); private static final org.objectweb.asm.commons.Method FIELD$GET = @@ -53,6 +58,9 @@ class ReflectorClassWriter extends ClassWriter { findMethod(Field.class, "set", new Class<?>[] {Object.class, Object.class}); private static final org.objectweb.asm.commons.Method METHOD$INVOKE = findMethod(Method.class, "invoke", new Class<?>[] {Object.class, Object[].class}); + private static final org.objectweb.asm.commons.Method CONSTRUCTOR$NEWINSTANCE = + findMethod( + java.lang.reflect.Constructor.class, "newInstance", new Class<?>[] {Object[].class}); private static final org.objectweb.asm.commons.Method THROWABLE$GET_CAUSE = findMethod(Throwable.class, "getCause", new Class<?>[] {}); private static final org.objectweb.asm.commons.Method OBJECT_INIT = @@ -118,8 +126,11 @@ class ReflectorClassWriter extends ClassWriter { if (method.isDefault()) continue; Accessor accessor = method.getAnnotation(Accessor.class); + Constructor constructor = method.getAnnotation(Constructor.class); if (accessor != null) { new AccessorMethodWriter(method, accessor).write(); + } else if (constructor != null) { + new ConstructorMethodWriter(method).write(); } else { new ReflectorMethodWriter(method).write(); } @@ -251,6 +262,135 @@ class ReflectorClassWriter extends ClassWriter { } } + private class ConstructorMethodWriter extends BaseAdapter { + + private final String constructorRefName; + private final Type[] targetParamTypes; + + private ConstructorMethodWriter(Method method) { + super(method); + int myMethodNumber = nextMethodNumber++; + this.constructorRefName = "constructor" + myMethodNumber; + this.targetParamTypes = resolveParamTypes(iMethod); + } + + void write() { + // write field to hold method reference... + visitField( + ACC_PRIVATE | ACC_STATIC, + constructorRefName, + CONSTRUCTOR_TYPE.getDescriptor(), + null, + null); + + visitCode(); + + // pseudocode: + // try { + // return constructorN.newInstance(*args); + // } catch (InvocationTargetException e) { + // throw e.getCause(); + // } catch (ReflectiveOperationException e) { + // throw new AssertionError("Error invoking reflector method in ClassLoader " + + // Instrumentation.class.getClassLoader(), e); + // } + Label tryStart = new Label(); + Label tryEnd = new Label(); + Label handleInvocationTargetException = new Label(); + visitTryCatchBlock( + tryStart, + tryEnd, + handleInvocationTargetException, + INVOCATION_TARGET_EXCEPTION_TYPE.getInternalName()); + Label handleReflectiveOperationException = new Label(); + visitTryCatchBlock( + tryStart, + tryEnd, + handleReflectiveOperationException, + REFLECTIVE_OPERATION_EXCEPTION_TYPE.getInternalName()); + + mark(tryStart); + loadOriginalConstructorRef(); + loadArgArray(); + invokeVirtual(CONSTRUCTOR_TYPE, CONSTRUCTOR$NEWINSTANCE); + mark(tryEnd); + + castForReturn(iMethod.getReturnType()); + returnValue(); + + mark(handleInvocationTargetException); + + int exceptionLocalVar = newLocal(THROWABLE_TYPE); + storeLocal(exceptionLocalVar); + loadLocal(exceptionLocalVar); + invokeVirtual(THROWABLE_TYPE, THROWABLE$GET_CAUSE); + throwException(); + mark(handleReflectiveOperationException); + exceptionLocalVar = newLocal(REFLECTIVE_OPERATION_EXCEPTION_TYPE); + storeLocal(exceptionLocalVar); + newInstance(STRINGBUILDER_TYPE); + dup(); + invokeConstructor(STRINGBUILDER_TYPE, OBJECT_INIT); + push("Error invoking reflector method in ClassLoader "); + invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$APPEND); + push(targetType); + invokeVirtual(CLASS_TYPE, CLASS$GET_CLASS_LOADER); + invokeStatic(STRING_TYPE, STRING$VALUE_OF); + invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$APPEND); + invokeVirtual(STRINGBUILDER_TYPE, STRINGBUILDER$TO_STRING); + int messageLocalVar = newLocal(STRING_TYPE); + storeLocal(messageLocalVar); + newInstance(ASSERTION_ERROR_TYPE); + dup(); + loadLocal(messageLocalVar); + loadLocal(exceptionLocalVar); + invokeConstructor(ASSERTION_ERROR_TYPE, ASSERTION_ERROR_INIT); + throwException(); + + endMethod(); + } + + private void loadOriginalConstructorRef() { + // pseudocode: + // if (constructorN == null) { + // constructorN = targetClass.getDeclaredConstructor(paramTypes); + // constructorN.setAccessible(true); + // } + // -> constructor reference on stack + getStatic(reflectorType, constructorRefName, CONSTRUCTOR_TYPE); + dup(); + Label haveConstructorRef = newLabel(); + ifNonNull(haveConstructorRef); + pop(); + + // pseudocode: + // targetClass.getDeclaredConstructor(paramTypes); + push(targetType); + Type[] paramTypes = targetParamTypes; + push(paramTypes.length); + newArray(CLASS_TYPE); + for (int i = 0; i < paramTypes.length; i++) { + dup(); + push(i); + push(paramTypes[i]); + arrayStore(CLASS_TYPE); + } + invokeVirtual(CLASS_TYPE, CLASS$GET_DECLARED_CONSTRUCTOR); + + // pseudocode: + // <constructor>.setAccessible(true); + dup(); + push(true); + invokeVirtual(CONSTRUCTOR_TYPE, ACCESSIBLE_OBJECT$SET_ACCESSIBLE); + + // pseudocode: + // constructorN = constructor; + dup(); + putStatic(reflectorType, constructorRefName, CONSTRUCTOR_TYPE); + mark(haveConstructorRef); + } + } + private class ReflectorMethodWriter extends BaseAdapter { private final String methodRefName; @@ -375,35 +515,6 @@ class ReflectorClassWriter extends ClassWriter { putStatic(reflectorType, methodRefName, METHOD_TYPE); mark(haveMethodRef); } - - private Type[] resolveParamTypes(Method iMethod) { - Class<?>[] iParamTypes = iMethod.getParameterTypes(); - Annotation[][] paramAnnotations = iMethod.getParameterAnnotations(); - - Type[] targetParamTypes = new Type[iParamTypes.length]; - for (int i = 0; i < iParamTypes.length; i++) { - Class<?> paramType = findWithType(paramAnnotations[i]); - if (paramType == null) { - paramType = iParamTypes[i]; - } - targetParamTypes[i] = Type.getType(paramType); - } - return targetParamTypes; - } - - private Class<?> findWithType(Annotation[] paramAnnotation) { - for (Annotation annotation : paramAnnotation) { - if (annotation instanceof WithType) { - String withTypeName = ((WithType) annotation).value(); - try { - return Class.forName(withTypeName, true, iClass.getClassLoader()); - } catch (ClassNotFoundException e1) { - // it's okay, ignore - } - } - } - return null; - } } private static String[] getInternalNames(final Class<?>[] types) { @@ -494,5 +605,35 @@ class ReflectorClassWriter extends ClassWriter { void loadNull() { visitInsn(Opcodes.ACONST_NULL); } + + protected Type[] resolveParamTypes(Method iMethod) { + Class<?>[] iParamTypes = iMethod.getParameterTypes(); + Annotation[][] paramAnnotations = iMethod.getParameterAnnotations(); + + Type[] targetParamTypes = new Type[iParamTypes.length]; + for (int i = 0; i < iParamTypes.length; i++) { + Class<?> paramType = findWithType(paramAnnotations[i]); + if (paramType == null) { + paramType = iParamTypes[i]; + } + targetParamTypes[i] = Type.getType(paramType); + } + return targetParamTypes; + } + + @Nullable + private Class<?> findWithType(Annotation[] paramAnnotation) { + for (Annotation annotation : paramAnnotation) { + if (annotation instanceof WithType) { + String withTypeName = ((WithType) annotation).value(); + try { + return Class.forName(withTypeName, true, iClass.getClassLoader()); + } catch (ClassNotFoundException e1) { + // it's okay, ignore + } + } + } + return null; + } } } diff --git a/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java b/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java index 8baf3d63e..74dc88487 100644 --- a/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java +++ b/utils/reflector/src/test/java/org/robolectric/util/reflector/ReflectorTest.java @@ -133,6 +133,25 @@ public class ReflectorTest { time("saved accessor", 10_000_000, () -> fieldBySavedReflector(accessor)); } + @Ignore + @Test + public void constructorPerf() { + SomeClass i = new SomeClass("c"); + + System.out.println("reflection = " + Collections.singletonList(methodByReflectionHelpers(i))); + System.out.println("accessor = " + Collections.singletonList(methodByReflector(i))); + + _SomeClass_ accessor = reflector(_SomeClass_.class, i); + + time("ReflectionHelpers", 10_000_000, this::constructorByReflectionHelpers); + time("accessor", 10_000_000, () -> constructorByReflector()); + time("saved accessor", 10_000_000, () -> constructorBySavedReflector(accessor)); + + time("ReflectionHelpers", 10_000_000, () -> constructorByReflectionHelpers()); + time("accessor", 10_000_000, () -> constructorByReflector()); + time("saved accessor", 10_000_000, () -> constructorBySavedReflector(accessor)); + } + @Test public void nonExistentMethod_throwsAssertionError() { SomeClass i = new SomeClass("c"); @@ -143,6 +162,11 @@ public class ReflectorTest { assertThat(ex).hasCauseThat().isInstanceOf(NoSuchMethodException.class); } + @Test + public void reflector_constructor() { + assertThat(staticReflector.newSomeClass("sdfsdf")).isNotNull(); + } + ////////////////////// /** Accessor interface for {@link SomeClass}'s internals. */ @@ -170,6 +194,9 @@ public class ReflectorTest { @Accessor("mD") int getD(); + @Constructor + SomeClass newSomeClass(String c); + String someMethod(String a, String b); String nonExistentMethod(String a, String b, String c); @@ -251,6 +278,20 @@ public class ReflectorTest { return reflector.someMethod("a", "b"); } + private SomeClass constructorByReflectionHelpers() { + return ReflectionHelpers.callConstructor( + SomeClass.class, ClassParameter.from(String.class, "a")); + } + + private SomeClass constructorByReflector() { + _SomeClass_ accessor = reflector(_SomeClass_.class); + return accessor.newSomeClass("a"); + } + + private SomeClass constructorBySavedReflector(_SomeClass_ reflector) { + return reflector.newSomeClass("a"); + } + private String fieldByReflectionHelpers(SomeClass o) { ReflectionHelpers.setField(o, "c", "abc"); return ReflectionHelpers.getField(o, "c"); |