diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2024-05-09 01:25:21 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2024-05-09 01:25:21 +0000 |
commit | 6ac624955b748f8a3a3b5595f1d6ffbc85a170eb (patch) | |
tree | 9b3ab586b33552f7346a35e83656da0023d3501c | |
parent | f609eaec10ee5793f6e046915da5042ad7ee9e5e (diff) | |
parent | 746f685a324dade8ef79b673aa9379de9f5c5c3c (diff) | |
download | robolectric-sdk-release.tar.gz |
Merge "Snap for 11819063 from be17efc0a8d51d690f8290bc32049b4eb8b4fc18 to sdk-release" into sdk-releasesdk-release
20 files changed, 696 insertions, 217 deletions
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52a90b6aa..ca186969d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -robolectric-nativeruntime-dist-compat = "1.0.9" +robolectric-nativeruntime-dist-compat = "1.0.10" # https://developer.android.com/studio/releases android-gradle = "8.3.1" diff --git a/integration_tests/kotlin/build.gradle b/integration_tests/kotlin/build.gradle index c95d64f3f..37686d981 100644 --- a/integration_tests/kotlin/build.gradle +++ b/integration_tests/kotlin/build.gradle @@ -18,6 +18,7 @@ dependencies { testCompileOnly AndroidSdk.MAX_SDK.coordinates testRuntimeOnly AndroidSdk.MAX_SDK.coordinates testImplementation libs.kotlin.stdlib + testImplementation libs.kotlinx.coroutines.android testImplementation libs.junit4 testImplementation libs.truth testImplementation "androidx.test:core:$axtCoreVersion@aar" diff --git a/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisioner.kt b/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisioner.kt new file mode 100644 index 000000000..2094dd13b --- /dev/null +++ b/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisioner.kt @@ -0,0 +1,79 @@ +package org.robolectric.integrationtests.kotlin.flow + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.content.Context +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow + +/** A class that invokes Android Bluetooth LE APIs. */ +class BluetoothProvisioner(applicationContext: Context) { + + val context: Context + + init { + context = applicationContext + } + + fun startScan(): Flow<BluetoothDevice> = callbackFlow { + val scanCallback = + object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + if (result?.device != null) { + val unused = trySend(result.device) + } + } + + override fun onScanFailed(errorCode: Int) { + cancel("BLE Scan Failed", null) + } + } + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val scanner = bluetoothManager.adapter.bluetoothLeScanner + scanner.startScan(scanCallback) + awaitClose { scanner.stopScan(scanCallback) } + } + + fun connectToDevice(device: BluetoothDevice): Flow<BluetoothGatt> = callbackFlow { + val gattCallback = + object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + val unused = gatt!!.discoverServices() + } else { + cancel("Connect Failed", null) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val unused = trySend(gatt!!) + } else { + cancel("Service discovery failed", null) + } + } + } + + device.connectGatt(context, true, gattCallback) + awaitClose {} + } + + fun scanAndConnect() = + flow<BluetoothGattService> { + val device = startScan().firstOrNull() + if (device != null) { + val gatt = connectToDevice(device).firstOrNull() + emit(gatt!!.services[0]) + } + } +} diff --git a/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisionerTest.kt b/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisionerTest.kt new file mode 100644 index 000000000..181b6a8b9 --- /dev/null +++ b/integration_tests/kotlin/src/test/kotlin/org/robolectric/integrationtests/kotlin/flow/BluetoothProvisionerTest.kt @@ -0,0 +1,83 @@ +package org.robolectric.integrationtests.kotlin.flow + +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanResult +import android.content.Context +import android.os.Build.VERSION_CODES.S +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.android.util.concurrent.PausedExecutorService +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowBluetoothDevice +import org.robolectric.shadows.ShadowBluetoothGatt +import org.robolectric.shadows.ShadowBluetoothLeScanner + +/** + * A test that uses a custom executor-backed coroutine dispatcher to control the execution of + * coroutines. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [S]) +class BluetoothProvisionerTest { + + val BLUETOOTH_MAC = "00:11:22:33:AA:BB" + + val context = RuntimeEnvironment.getApplication() + + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + + fun newScanResult(): ScanResult { + val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(BLUETOOTH_MAC) + return ScanResult(bluetoothDevice, null, 0, 0) + } + + @Test + fun testBluetoothProvisioner() { + val executor = PausedExecutorService() + val dispatcher = executor.asCoroutineDispatcher() + val scope = CoroutineScope(dispatcher) + + scope.launch { + val gattService = + BluetoothProvisioner(RuntimeEnvironment.getApplication()).scanAndConnect().firstOrNull() + assertThat(gattService).isNotNull() + } + + executor.runAll() + + val scanner = bluetoothManager.adapter.bluetoothLeScanner + val shadowScanner = Shadow.extract<ShadowBluetoothLeScanner>(scanner) + + val scanResult = newScanResult() + val bluetoothDevice = scanResult.device + shadowScanner.scanCallbacks.first().onScanResult(0, newScanResult()) + + executor.runAll() + + val shadowDevice = Shadow.extract<ShadowBluetoothDevice>(bluetoothDevice) + + val gatt = shadowDevice.bluetoothGatts.first() + val shadowGatt = Shadow.extract<ShadowBluetoothGatt>(gatt) + + val service = + BluetoothGattService( + UUID.fromString("00000000-0000-0000-0000-0000000000A1"), + BluetoothGattService.SERVICE_TYPE_PRIMARY, + ) + + shadowGatt.addDiscoverableService(service) + shadowGatt.notifyConnection(BLUETOOTH_MAC) + + executor.runAll() + } +} diff --git a/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java b/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java index 803f82363..92280825e 100644 --- a/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java +++ b/resources/src/main/java/org/robolectric/res/android/CppAssetManager2.java @@ -885,7 +885,10 @@ public class CppAssetManager2 { out_value.set(device_value.copy()); // Convert the package ID to the runtime assigned package ID. - entry.get().dynamic_ref_table.lookupResourceValue(out_value); + int err = entry.get().dynamic_ref_table.lookupResourceValue(out_value); + if (err != NO_ERROR) { + return K_INVALID_COOKIE; + } out_selected_config.set(new ResTable_config(entry.get().config)); out_flags.set(entry.get().type_flags); diff --git a/resources/src/main/java/org/robolectric/res/android/DataType.java b/resources/src/main/java/org/robolectric/res/android/DataType.java index 30938926a..b238544b6 100644 --- a/resources/src/main/java/org/robolectric/res/android/DataType.java +++ b/resources/src/main/java/org/robolectric/res/android/DataType.java @@ -70,6 +70,8 @@ public enum DataType { } public static DataType fromCode(byte code) { - return Preconditions.checkNotNull(FROM_BYTE.get(code), "Unknown resource type: %s", code); + DataType type = FROM_BYTE.get(code); + Preconditions.checkArgument(type != null, "Unknown resource type: %s", code); + return type; } } diff --git a/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java b/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java index 0604116be..537a9f2f2 100644 --- a/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java +++ b/resources/src/main/java/org/robolectric/res/android/DynamicRefTable.java @@ -1,7 +1,9 @@ package org.robolectric.res.android; -// transliterated from https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h +// transliterated from +// https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r12/include/androidfw/ResourceTypes.h +import static org.robolectric.res.android.Errors.BAD_TYPE; import static org.robolectric.res.android.Errors.NO_ERROR; import static org.robolectric.res.android.Errors.UNKNOWN_ERROR; import static org.robolectric.res.android.ResTable.APP_PACKAGE_ID; @@ -145,7 +147,15 @@ public class DynamicRefTable int lookupResourceValue(Ref<Res_value> value) { byte resolvedType = DataType.REFERENCE.code(); Res_value inValue = value.get(); - switch (DataType.fromCode(inValue.dataType)) { + + DataType dataType; + try { + dataType = DataType.fromCode(inValue.dataType); + } catch (IllegalArgumentException e) { + return BAD_TYPE; + } + + switch (dataType) { case ATTRIBUTE: resolvedType = DataType.ATTRIBUTE.code(); // fallthrough diff --git a/resources/src/test/java/org/robolectric/res/android/DataTypeTest.java b/resources/src/test/java/org/robolectric/res/android/DataTypeTest.java new file mode 100644 index 000000000..3dea9d35e --- /dev/null +++ b/resources/src/test/java/org/robolectric/res/android/DataTypeTest.java @@ -0,0 +1,23 @@ +package org.robolectric.res.android; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class DataTypeTest { + + @Test + public void fromCode_shouldThrowExceptionForInvalidCode() { + assertThrows(IllegalArgumentException.class, () -> DataType.fromCode(99)); + } + + @Test + public void fromCode_shouldReturnCorrectDataTypeForValidCode() { + assertThat(DataType.fromCode(0)).isEqualTo(DataType.NULL); + assertThat(DataType.fromCode(3)).isEqualTo(DataType.STRING); + } +} diff --git a/resources/src/test/java/org/robolectric/res/android/DynamicRefTableTest.java b/resources/src/test/java/org/robolectric/res/android/DynamicRefTableTest.java new file mode 100644 index 000000000..f03578626 --- /dev/null +++ b/resources/src/test/java/org/robolectric/res/android/DynamicRefTableTest.java @@ -0,0 +1,23 @@ +package org.robolectric.res.android; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.res.android.Errors.BAD_TYPE; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.robolectric.res.android.ResourceTypes.Res_value; + +@RunWith(JUnit4.class) +public final class DynamicRefTableTest { + + private static final Ref<Res_value> RES_VALUE_OF_BAD_TYPE = + new Ref<>(new Res_value(/* dataType= */ (byte) 99, /* data= */ 0)); + + @Test + public void lookupResourceValue_returnsBadTypeIfTypeOutOfEnumRange() { + DynamicRefTable pseudoRefTable = + new DynamicRefTable(/* packageId= */ (byte) 0, /* appAsLib= */ true); + assertThat(pseudoRefTable.lookupResourceValue(RES_VALUE_OF_BAD_TYPE)).isEqualTo(BAD_TYPE); + } +} diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java index 470eeff75..bb9e408fd 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowCaptioningManagerTest.java @@ -1,31 +1,81 @@ package org.robolectric.shadows; +import static android.os.Build.VERSION_CODES.KITKAT; +import static android.os.Build.VERSION_CODES.TIRAMISU; import static com.google.common.truth.Truth.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.robolectric.Shadows.shadowOf; import android.content.Context; +import android.os.Looper; +import android.provider.Settings; +import android.provider.Settings.Secure; import android.view.accessibility.CaptioningManager; +import android.view.accessibility.CaptioningManager.CaptionStyle; import android.view.accessibility.CaptioningManager.CaptioningChangeListener; +import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.Locale; +import javax.annotation.Nullable; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; /** Tests for the ShadowCaptioningManager. */ @RunWith(AndroidJUnit4.class) -@Config(minSdk = 19) +@Config(minSdk = KITKAT) public final class ShadowCaptioningManagerTest { - @Mock private CaptioningChangeListener captioningChangeListener; + private TestCaptioningChangeListener captioningChangeListener = + new TestCaptioningChangeListener(); + + private static final int ENABLED = 1; + private static final int DISABLED = 0; private CaptioningManager captioningManager; + private Context context; + + public class TestCaptioningChangeListener extends CaptioningChangeListener { + public boolean isEnabled = false; + @Nullable public CaptionStyle captionStyle = null; + @Nullable public Locale locale = null; + public float fontScale = 1.0f; + public boolean systemAudioCaptioningEnabled = false; + public boolean systemAudioCaptioningUiEnabled = false; + + @Override + public void onEnabledChanged(boolean enabled) { + isEnabled = enabled; + } + + @Override + public void onUserStyleChanged(@NonNull CaptionStyle userStyle) { + captionStyle = userStyle; + } + + @Override + public void onLocaleChanged(@Nullable Locale locale) { + this.locale = locale; + } + + @Override + public void onFontScaleChanged(float fontScale) { + this.fontScale = fontScale; + } + + @Override + public void onSystemAudioCaptioningChanged(boolean enabled) { + this.systemAudioCaptioningEnabled = enabled; + } + + @Override + public void onSystemAudioCaptioningUiChanged(boolean enabled) { + this.systemAudioCaptioningUiEnabled = enabled; + } + } @Before public void setUp() { @@ -34,87 +84,106 @@ public final class ShadowCaptioningManagerTest { (CaptioningManager) ApplicationProvider.getApplicationContext() .getSystemService(Context.CAPTIONING_SERVICE); + context = RuntimeEnvironment.getApplication(); } @Test public void setEnabled_true() { - assertThat(captioningManager.isEnabled()).isFalse(); - - shadowOf(captioningManager).setEnabled(true); + Settings.Secure.putInt( + context.getContentResolver(), Secure.ACCESSIBILITY_CAPTIONING_ENABLED, ENABLED); assertThat(captioningManager.isEnabled()).isTrue(); } @Test public void setEnabled_false() { - shadowOf(captioningManager).setEnabled(false); + Settings.Secure.putInt( + context.getContentResolver(), Secure.ACCESSIBILITY_CAPTIONING_ENABLED, DISABLED); assertThat(captioningManager.isEnabled()).isFalse(); } @Test - public void setFontScale_changesValueOfGetFontScale() { - float fontScale = 1.5f; - shadowOf(captioningManager).setFontScale(fontScale); + public void setEnabled_callsCallback() { + captioningManager.addCaptioningChangeListener(captioningChangeListener); + Settings.Secure.putInt( + context.getContentResolver(), Secure.ACCESSIBILITY_CAPTIONING_ENABLED, ENABLED); - assertThat(captioningManager.getFontScale()).isWithin(0.001f).of(fontScale); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(captioningChangeListener.isEnabled).isTrue(); } @Test - public void setFontScale_notifiesObservers() { - float fontScale = 1.5f; - captioningManager.addCaptioningChangeListener(captioningChangeListener); - - shadowOf(captioningManager).setFontScale(fontScale); + public void setFontScale_updatesValue() { + Settings.Secure.putFloat( + context.getContentResolver(), Secure.ACCESSIBILITY_CAPTIONING_FONT_SCALE, 2.0f); - verify(captioningChangeListener).onFontScaleChanged(fontScale); + assertThat(captioningManager.getFontScale()).isEqualTo(2.0f); } @Test - public void addCaptioningChangeListener_doesNotRegisterSameListenerTwice() { - float fontScale = 1.5f; + public void setFontScale_callsCallback() { captioningManager.addCaptioningChangeListener(captioningChangeListener); + Settings.Secure.putFloat( + context.getContentResolver(), Secure.ACCESSIBILITY_CAPTIONING_FONT_SCALE, 3.0f); - captioningManager.addCaptioningChangeListener(captioningChangeListener); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(captioningChangeListener.fontScale).isEqualTo(3.0f); + } - shadowOf(captioningManager).setFontScale(fontScale); - verify(captioningChangeListener).onFontScaleChanged(fontScale); + @Test + public void setLocale_updatesValue() { + Settings.Secure.putString( + context.getContentResolver(), + Secure.ACCESSIBILITY_CAPTIONING_LOCALE, + Locale.JAPANESE.toLanguageTag()); + + assertThat(captioningManager.getLocale()).isEqualTo(Locale.JAPANESE); } @Test - public void removeCaptioningChangeListener_unregistersFontScaleListener() { + public void setLocale_callsCallback() { captioningManager.addCaptioningChangeListener(captioningChangeListener); + Settings.Secure.putString( + context.getContentResolver(), + Secure.ACCESSIBILITY_CAPTIONING_LOCALE, + Locale.FRENCH.toLanguageTag()); - captioningManager.removeCaptioningChangeListener(captioningChangeListener); - - shadowOf(captioningManager).setFontScale(1.5f); - verifyNoMoreInteractions(captioningChangeListener); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(captioningChangeListener.locale).isEqualTo(Locale.FRENCH); } @Test - public void setLocale_nonNull() { - Locale locale = Locale.US; - assertThat(captioningManager.getLocale()).isNull(); - - shadowOf(captioningManager).setLocale(locale); + @Config(minSdk = TIRAMISU) + public void setSystemAudioCaptioningEnabled_updatesValue() { + captioningManager.setSystemAudioCaptioningEnabled(true); - assertThat(captioningManager.getLocale()).isEqualTo(locale); + assertThat(captioningManager.isSystemAudioCaptioningEnabled()).isEqualTo(true); } @Test - public void setLocale_null() { - shadowOf(captioningManager).setLocale(null); + @Config(minSdk = TIRAMISU) + public void setSystemAudioCaptioningEnabled_callsCallback() { + captioningManager.setSystemAudioCaptioningEnabled(false); - assertThat(captioningManager.getLocale()).isNull(); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(captioningChangeListener.systemAudioCaptioningEnabled).isEqualTo(false); } @Test - public void setLocale_notifiesObservers() { - Locale locale = Locale.US; - captioningManager.addCaptioningChangeListener(captioningChangeListener); + @Config(minSdk = TIRAMISU) + public void setSystemAudioCaptioningUiEnabled_updatesValue() { + captioningManager.setSystemAudioCaptioningUiEnabled(true); + + assertThat(captioningManager.isSystemAudioCaptioningUiEnabled()).isEqualTo(true); + } - shadowOf(captioningManager).setLocale(locale); + @Test + @Config(minSdk = TIRAMISU) + public void setSystemAudioCaptioningUiEnabled_callsCallback() { + captioningManager.setSystemAudioCaptioningUiEnabled(false); - verify(captioningChangeListener).onLocaleChanged(locale); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(captioningChangeListener.systemAudioCaptioningUiEnabled).isEqualTo(false); } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java index 4da4963cd..459b75b65 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowEGL14Test.java @@ -27,8 +27,10 @@ public final class ShadowEGL14Test { } @Test - public void eglChooseConfig() { + public void eglChooseConfig_retrieveConfigs() { EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + // When the config array is not null, numConfig is filled with the number of configs returned + // (up to the size of the config array). EGLConfig[] configs = new EGLConfig[1]; int[] numConfig = new int[1]; assertThat(EGL14.eglChooseConfig(display, new int[0], 0, configs, 0, 1, numConfig, 0)).isTrue(); @@ -37,6 +39,16 @@ public final class ShadowEGL14Test { } @Test + public void eglChooseConfig_countMatching() { + EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + // When the config array is null, numConfig is filled with the total number of configs matched. + EGLConfig[] configs = null; + int[] numConfig = new int[1]; + assertThat(EGL14.eglChooseConfig(display, new int[0], 0, configs, 0, 0, numConfig, 0)).isTrue(); + assertThat(numConfig[0]).isGreaterThan(0); + } + + @Test public void eglCreateContext_v2() { EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); int[] attribList = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java index c0ff1622f..25c7ed722 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java @@ -18,6 +18,7 @@ import android.speech.SpeechRecognizer; import android.util.Log; import androidx.test.core.app.ApplicationProvider; import java.util.ArrayList; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -42,6 +43,11 @@ public class ShadowSpeechRecognizerTest { supportCallback = new TestRecognitionSupportCallback(); } + @After + public void tearDown() { + speechRecognizer.destroy(); + } + @Test public void onErrorCalled() { startListening(); @@ -118,6 +124,7 @@ public class ShadowSpeechRecognizerTest { shadowOf(speechRecognizer).triggerOnResults(new Bundle()); speechRecognizer.stopListening(); + shadowOf(getMainLooper()).idle(); assertNoErrorLogs(); } @@ -136,6 +143,8 @@ public class ShadowSpeechRecognizerTest { /** Verify the startlistening flow works when using custom component name. */ @Test public void startListeningWithCustomComponent() { + speechRecognizer.destroy(); + speechRecognizer = SpeechRecognizer.createSpeechRecognizer( ApplicationProvider.getApplicationContext(), @@ -157,6 +166,7 @@ public class ShadowSpeechRecognizerTest { shadowOf(getMainLooper()).idle(); assertThat(ShadowSpeechRecognizer.getLatestSpeechRecognizer()) .isSameInstanceAs(newSpeechRecognizer); + newSpeechRecognizer.destroy(); } @Test @@ -170,6 +180,7 @@ public class ShadowSpeechRecognizerTest { newSpeechRecognizer.startListening(intent); shadowOf(getMainLooper()).idle(); assertThat(shadowOf(newSpeechRecognizer).getLastRecognizerIntent()).isEqualTo(intent); + newSpeechRecognizer.destroy(); } private void startListening() { @@ -236,6 +247,7 @@ public class ShadowSpeechRecognizerTest { @Config(minSdk = TIRAMISU) @Test public void onCreateOnDeviceRecognizer_setsLatestSpeechRecognizer() { + speechRecognizer.destroy(); speechRecognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(applicationContext); assertThat(speechRecognizer) diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java index c3535c043..ead36aeea 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowTelephonyManagerTest.java @@ -1530,4 +1530,21 @@ public class ShadowTelephonyManagerTest { IllegalStateException.class, () -> shadowTelephonyManager.setCarrierRestrictionRules(new Object())); } + + @Test + @Config(minSdk = Q) + public void rebootModem_rebootsModem() { + shadowOf((Application) ApplicationProvider.getApplicationContext()) + .grantPermissions(permission.MODIFY_PHONE_STATE); + + shadowTelephonyManager.rebootModem(); + + assertThat(shadowTelephonyManager.getModemRebootCount()).isEqualTo(1); + } + + @Test() + @Config(minSdk = Q) + public void rebootModem_noModifyPhoneStatePermission_throwsSecurityException() { + assertThrows(SecurityException.class, () -> shadowTelephonyManager.rebootModem()); + } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/HardwareRenderingScreenshot.java b/shadows/framework/src/main/java/org/robolectric/shadows/HardwareRenderingScreenshot.java index 63e9d7bd8..4bb433f90 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/HardwareRenderingScreenshot.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/HardwareRenderingScreenshot.java @@ -15,7 +15,10 @@ import android.os.Build.VERSION_CODES; import android.util.DisplayMetrics; import android.view.Surface; import android.view.View; +import android.view.ViewRootImpl; import com.android.internal.R; +import com.google.common.base.Preconditions; +import java.util.WeakHashMap; import org.robolectric.annotation.GraphicsMode; import org.robolectric.util.ReflectionHelpers; @@ -25,6 +28,11 @@ import org.robolectric.util.ReflectionHelpers; */ public final class HardwareRenderingScreenshot { + // It is important to reuse HardwareRenderer objects, and ensure that after a HardwareRenderer is + // collected, no associated views in the same View hierarchy will be rendered as well. + private static final WeakHashMap<ViewRootImpl, HardwareRenderer> hardwareRenderers = + new WeakHashMap<>(); + static final String PIXEL_COPY_RENDER_MODE = "robolectric.pixelCopyRenderMode"; private HardwareRenderingScreenshot() {} @@ -58,8 +66,10 @@ public final class HardwareRenderingScreenshot { // - ImageReader is configured as RGBA_8888. // - However the native libs/hwui/pipeline/skia/SkiaHostPipeline.cpp always treats // the buffer as BGRA_8888, thus matching what the Android Bitmap object requires. - - HardwareRenderer renderer = new HardwareRenderer(); + ViewRootImpl viewRootImpl = view.getViewRootImpl(); + Preconditions.checkNotNull(viewRootImpl, "View not attached"); + HardwareRenderer renderer = + hardwareRenderers.computeIfAbsent(viewRootImpl, k -> new HardwareRenderer()); Surface surface = imageReader.getSurface(); renderer.setSurface(surface); Image nativeImage = imageReader.acquireNextImage(); diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java index c4e614dd5..5853071dd 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBluetoothLeAdvertiser.java @@ -4,11 +4,13 @@ import static android.os.Build.VERSION_CODES.O; import static android.os.Build.VERSION_CODES.R; import static android.os.Build.VERSION_CODES.S; import static android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE; +import static org.robolectric.util.reflector.Reflector.reflector; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGattServer; import android.bluetooth.BluetoothUuid; +import android.bluetooth.IBluetoothGatt; import android.bluetooth.IBluetoothManager; import android.bluetooth.le.AdvertiseCallback; import android.bluetooth.le.AdvertiseData; @@ -26,14 +28,16 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; 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; import org.robolectric.util.PerfStatsCollector; import org.robolectric.util.ReflectionHelpers; -import org.robolectric.util.ReflectionHelpers.ClassParameter; +import org.robolectric.util.reflector.Constructor; import org.robolectric.util.reflector.Direct; import org.robolectric.util.reflector.ForType; +import org.robolectric.versioning.AndroidVersions.V; /** Shadow implementation of {@link BluetoothLeAdvertiser}. */ @Implements(value = BluetoothLeAdvertiser.class, minSdk = O) @@ -231,16 +235,28 @@ public class ShadowBluetoothLeAdvertiser { return; } - AdvertisingSet advertisingSet = - ReflectionHelpers.callConstructor( - AdvertisingSet.class, - ClassParameter.from(int.class, advertiserId.getAndAdd(1)), - ClassParameter.from( - IBluetoothManager.class, - ReflectionHelpers.createNullProxy(IBluetoothManager.class)), - ClassParameter.from( - AttributionSource.class, - ReflectionHelpers.callInstanceMethod(bluetoothAdapter, "getAttributionSource"))); + AdvertisingSet advertisingSet; + if (RuntimeEnvironment.getApiLevel() >= V.SDK_INT) { + IBluetoothGatt gatt = + ReflectionHelpers.callInstanceMethod(bluetoothAdapter, "getBluetoothGatt"); + + advertisingSet = + reflector(AdvertisingSetReflector.class) + .__constructor__( + gatt == null ? ReflectionHelpers.createNullProxy(IBluetoothGatt.class) : gatt, + advertiserId.getAndAdd(1), + bluetoothAdapter, + bluetoothAdapter.getAttributionSource()); + } else { + advertisingSet = + reflector(AdvertisingSetReflector.class) + .__constructor__( + advertiserId.getAndAdd(1), + ReflectionHelpers.createNullProxy(IBluetoothManager.class), + (AttributionSource) + ReflectionHelpers.callInstanceMethod( + bluetoothAdapter, "getAttributionSource")); + } callback.onAdvertisingSetStarted( advertisingSet, parameters.getTxPowerLevel(), AdvertisingSetCallback.ADVERTISE_SUCCESS); @@ -343,4 +359,18 @@ public class ShadowBluetoothLeAdvertiser { @Direct void __constructor__(BluetoothAdapter bluetoothAdapter); } + + @ForType(AdvertisingSet.class) + interface AdvertisingSetReflector { + @Constructor + AdvertisingSet __constructor__( + IBluetoothGatt bluetoothGatt, + int advertiserId, + BluetoothAdapter bluetoothAdapter, + AttributionSource attributionSource); + + @Constructor + AdvertisingSet __constructor__( + int advertiserId, IBluetoothManager bluetoothManager, AttributionSource attributionSource); + } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java index 19762d193..203e43968 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowCaptioningManager.java @@ -1,72 +1,48 @@ package org.robolectric.shadows; -import android.annotation.NonNull; -import android.util.ArraySet; +import static android.os.Build.VERSION_CODES.TIRAMISU; +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.content.ContentResolver; +import android.provider.Settings; import android.view.accessibility.CaptioningManager; -import android.view.accessibility.CaptioningManager.CaptioningChangeListener; -import java.util.Locale; -import javax.annotation.Nullable; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; +import org.robolectric.util.reflector.Accessor; +import org.robolectric.util.reflector.ForType; /** Shadow of {@link android.view.accessibility.CaptioningManager}. */ @Implements(CaptioningManager.class) public class ShadowCaptioningManager { - private float fontScale = 1; - private boolean isEnabled = false; - @Nullable private Locale locale; - - private final ArraySet<CaptioningChangeListener> listeners = new ArraySet<>(); - - /** Returns 1.0 as default or the most recent value passed to {@link #setFontScale()} */ - @Implementation(minSdk = 19) - protected float getFontScale() { - return fontScale; - } - - /** Sets the value to be returned by {@link CaptioningManager#getFontScale()} */ - public void setFontScale(float fontScale) { - this.fontScale = fontScale; + @RealObject private CaptioningManager realCaptioningManager; - for (CaptioningChangeListener captioningChangeListener : listeners) { - captioningChangeListener.onFontScaleChanged(fontScale); - } + @Implementation(minSdk = TIRAMISU) + protected void setSystemAudioCaptioningEnabled(boolean isEnabled) { + Settings.Secure.putInt( + getContentResolver(), Settings.Secure.ODI_CAPTIONS_ENABLED, isEnabled ? 1 : 0); } - /** Returns false or the most recent value passed to {@link #setEnabled(boolean)} */ - @Implementation(minSdk = 19) - protected boolean isEnabled() { - return isEnabled; + @Implementation(minSdk = TIRAMISU) + protected void setSystemAudioCaptioningUiEnabled(boolean isEnabled) { + Settings.Secure.putInt( + getContentResolver(), Settings.Secure.ODI_CAPTIONS_VOLUME_UI_ENABLED, isEnabled ? 1 : 0); } - /** Sets the value to be returned by {@link CaptioningManager#isEnabled()} */ - public void setEnabled(boolean isEnabled) { - this.isEnabled = isEnabled; + @Implementation(minSdk = TIRAMISU) + protected boolean isSystemAudioCaptioningUiEnabled() { + return Settings.Secure.getInt( + getContentResolver(), Settings.Secure.ODI_CAPTIONS_VOLUME_UI_ENABLED, 1) + == 1; } - @Implementation(minSdk = 19) - protected void addCaptioningChangeListener(@NonNull CaptioningChangeListener listener) { - listeners.add(listener); + private ContentResolver getContentResolver() { + return reflector(CaptioningManagerReflector.class, realCaptioningManager).getContentResolver(); } - @Implementation(minSdk = 19) - protected void removeCaptioningChangeListener(@NonNull CaptioningChangeListener listener) { - listeners.remove(listener); - } - - /** Returns null or the most recent value passed to {@link #setLocale(Locale)} */ - @Implementation(minSdk = 19) - @Nullable - protected Locale getLocale() { - return locale; - } - - /** Sets the value to be returned by {@link CaptioningManager#getLocale()} */ - public void setLocale(@Nullable Locale locale) { - this.locale = locale; - - for (CaptioningChangeListener captioningChangeListener : listeners) { - captioningChangeListener.onLocaleChanged(locale); - } + @ForType(CaptioningManager.class) + interface CaptioningManagerReflector { + @Accessor("mContentResolver") + ContentResolver getContentResolver(); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java index 4f488b571..547b9f1ff 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowEGL14.java @@ -37,7 +37,11 @@ public class ShadowEGL14 { int configSize, int[] numConfig, int numConfigOffset) { - configs[configsOffset] = createEglConfig(); + // The configs array here can be null, in which case the numConfig output is supposed to be + // set to the number of matching configs instead of the number of returned configs. + if (configs != null) { + configs[configsOffset] = createEglConfig(); + } numConfig[numConfigOffset] = 1; return true; } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java index c3b4a3e41..e5997eee9 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInCallService.java @@ -207,9 +207,9 @@ public class ShadowInCallService extends ShadowService { InCallAdapter adapter = Shadow.newInstanceOf(InCallAdapter.class); Phone phone; if (VERSION.SDK_INT > N_MR1) { - phone = reflector(ReflectorPhone.class, inCallService).newInstance(adapter, "", 0); + phone = reflector(ReflectorPhone.class).newInstance(adapter, "", 0); } else { - phone = reflector(ReflectorPhone.class, inCallService).newInstance(adapter); + phone = reflector(ReflectorPhone.class).newInstance(adapter); } ReflectionHelpers.setField(inCallService, "mPhone", phone); } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java index ec5a63cc4..b7375e557 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java @@ -11,10 +11,13 @@ import android.content.Intent; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Handler; +import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.speech.IRecognitionService; import android.speech.RecognitionListener; +import android.speech.RecognitionSupport; +import android.speech.RecognitionSupportCallback; import android.speech.SpeechRecognizer; import com.google.common.base.Preconditions; import java.util.Queue; @@ -35,34 +38,16 @@ import org.robolectric.versioning.AndroidVersions.U; @Implements(value = SpeechRecognizer.class, looseSignatures = true) public class ShadowSpeechRecognizer { - @RealObject SpeechRecognizer realSpeechRecognizer; - protected static SpeechRecognizer latestSpeechRecognizer; - private Intent recognizerIntent; - private RecognitionListener recognitionListener; - private static boolean isOnDeviceRecognitionAvailable = true; - private boolean isRecognizerDestroyed = false; + @SuppressWarnings("NonFinalStaticField") + private static SpeechRecognizer latestSpeechRecognizer; - private /*RecognitionSupportCallback*/ Object recognitionSupportCallback; - private Executor recognitionSupportExecutor; - @Nullable private Intent latestModelDownloadIntent; - - /** - * Returns the latest SpeechRecognizer. This method can only be called after {@link - * SpeechRecognizer#createSpeechRecognizer(Context)} is called. - */ - public static SpeechRecognizer getLatestSpeechRecognizer() { - return latestSpeechRecognizer; - } + @SuppressWarnings("NonFinalStaticField") + private static boolean isOnDeviceRecognitionAvailable = true; - /** Returns the argument passed to the last call to {@link SpeechRecognizer#startListening}. */ - public Intent getLastRecognizerIntent() { - return recognizerIntent; - } + @RealObject SpeechRecognizer realSpeechRecognizer; - /** Returns true iff the destroy method of was invoked for the recognizer. */ - public boolean isDestroyed() { - return isRecognizerDestroyed; - } + // NOTE: Do not manipulate state directly in this class. Call {@link #getState()} instead. + private final ShadowSpeechRecognizerState state = new ShadowSpeechRecognizerState(); @Resetter public static void reset() { @@ -70,10 +55,12 @@ public class ShadowSpeechRecognizer { isOnDeviceRecognitionAvailable = true; } - @Implementation - protected void destroy() { - isRecognizerDestroyed = true; - reflector(SpeechRecognizerReflector.class, realSpeechRecognizer).destroy(); + /** + * Returns the latest SpeechRecognizer. This method can only be called after {@link + * SpeechRecognizer#createSpeechRecognizer(Context)} is called. + */ + public static SpeechRecognizer getLatestSpeechRecognizer() { + return latestSpeechRecognizer; } @Implementation @@ -86,145 +73,264 @@ public class ShadowSpeechRecognizer { return result; } - @Implementation - protected void startListening(Intent recognizerIntent) { - this.recognizerIntent = recognizerIntent; - // from the implementation of {@link SpeechRecognizer#startListening} it seems that it allows - // running the method on an already destroyed object, so we replicate the same by resetting - // isRecognizerDestroyed - isRecognizerDestroyed = false; - // the real implementation connects to a service - // simulate the resulting behavior once the service is connected - Handler mainHandler = new Handler(Looper.getMainLooper()); - // perform the onServiceConnected logic - mainHandler.post( - () -> { - SpeechRecognizerReflector recognizerReflector = - reflector(SpeechRecognizerReflector.class, realSpeechRecognizer); - recognizerReflector.setService( - ReflectionHelpers.createNullProxy(IRecognitionService.class)); - Queue<Message> pendingTasks = recognizerReflector.getPendingTasks(); - while (!pendingTasks.isEmpty()) { - recognizerReflector.getHandler().sendMessage(pendingTasks.poll()); - } - }); + @Implementation(minSdk = VERSION_CODES.TIRAMISU) + protected static SpeechRecognizer createOnDeviceSpeechRecognizer(final Context context) { + SpeechRecognizer result = + reflector(SpeechRecognizerReflector.class).createOnDeviceSpeechRecognizer(context); + latestSpeechRecognizer = result; + return result; + } + + public static void setIsOnDeviceRecognitionAvailable(boolean available) { + isOnDeviceRecognitionAvailable = available; + } + + @Implementation(minSdk = VERSION_CODES.TIRAMISU) + protected static boolean isOnDeviceRecognitionAvailable(final Context context) { + return isOnDeviceRecognitionAvailable; + } + + /** + * Returns the state of this shadow instance. + * + * <p>Subclasses may override this function to customize which state is returned. + */ + protected ShadowSpeechRecognizerState getState() { + return state; } /** - * Handles changing the listener and allows access to the internal listener to trigger events and - * sets the latest SpeechRecognizer. + * Returns the {@link ShadowSpeechRecognizerDirectAccessors} implementation that can handle direct + * access to functions/variables of a real {@link SpeechRecognizer}. + * + * <p>Subclasses may override this function to customize access in case they are shadowing a + * subclass of {@link SpeechRecognizer} that functions differently than the parent class. */ + protected ShadowSpeechRecognizerDirectAccessors getDirectAccessors() { + return reflector(SpeechRecognizerReflector.class, realSpeechRecognizer); + } + + /** Returns true iff the destroy method of was invoked for the recognizer. */ + public boolean isDestroyed() { + return getState().isRecognizerDestroyed; + } + + @Implementation(maxSdk = U.SDK_INT) + protected void destroy() { + getState().isRecognizerDestroyed = true; + getDirectAccessors().destroy(); + } + + /** Returns the argument passed to the last call to {@link SpeechRecognizer#startListening}. */ + public Intent getLastRecognizerIntent() { + return getState().recognizerIntent; + } + + @Implementation(maxSdk = U.SDK_INT) + protected void startListening(Intent recognizerIntent) { + // Record the most recent requested intent. + ShadowSpeechRecognizerState shadowState = getState(); + shadowState.recognizerIntent = recognizerIntent; + + // From the implementation of {@link SpeechRecognizer#startListening} it seems that it allows + // running the method on an already destroyed object, so we replicate the same by resetting + // isRecognizerDestroyed. + shadowState.isRecognizerDestroyed = false; + + // The real implementation connects to a service simulate the resulting behavior once + // the service is connected. + new Handler(Looper.getMainLooper()) + .post( + () -> { + ShadowSpeechRecognizerDirectAccessors directAccessors = getDirectAccessors(); + directAccessors.setService(createFakeSpeechRecognitionService()); + + Handler taskHandler = directAccessors.getHandler(); + Queue<Message> pendingTasks = directAccessors.getPendingTasks(); + while (!pendingTasks.isEmpty()) { + taskHandler.sendMessage(pendingTasks.poll()); + } + }); + } + + /** Handles changing the listener and allows access to the internal listener to trigger events. */ @Implementation(maxSdk = U.SDK_INT) // TODO(hoisie): Update this to support Android V @InDevelopment protected void handleChangeListener(RecognitionListener listener) { - recognitionListener = listener; + getState().recognitionListener = listener; } public void triggerOnEndOfSpeech() { - recognitionListener.onEndOfSpeech(); + getState().recognitionListener.onEndOfSpeech(); } public void triggerOnError(int error) { - recognitionListener.onError(error); + getState().recognitionListener.onError(error); } public void triggerOnReadyForSpeech(Bundle bundle) { - recognitionListener.onReadyForSpeech(bundle); + getState().recognitionListener.onReadyForSpeech(bundle); } public void triggerOnPartialResults(Bundle bundle) { - recognitionListener.onPartialResults(bundle); + getState().recognitionListener.onPartialResults(bundle); } public void triggerOnResults(Bundle bundle) { - recognitionListener.onResults(bundle); + getState().recognitionListener.onResults(bundle); } public void triggerOnRmsChanged(float rmsdB) { - recognitionListener.onRmsChanged(rmsdB); - } - - @Implementation(minSdk = VERSION_CODES.TIRAMISU) - protected static SpeechRecognizer createOnDeviceSpeechRecognizer(final Context context) { - SpeechRecognizer result = - reflector(SpeechRecognizerReflector.class).createOnDeviceSpeechRecognizer(context); - latestSpeechRecognizer = result; - return result; - } - - @Implementation(minSdk = VERSION_CODES.TIRAMISU) - protected static boolean isOnDeviceRecognitionAvailable(final Context context) { - return isOnDeviceRecognitionAvailable; + getState().recognitionListener.onRmsChanged(rmsdB); } @RequiresApi(api = VERSION_CODES.TIRAMISU) - @Implementation(minSdk = VERSION_CODES.TIRAMISU) + @Implementation(minSdk = VERSION_CODES.TIRAMISU, maxSdk = U.SDK_INT) protected void checkRecognitionSupport( @NonNull /*Intent*/ Object recognizerIntent, @NonNull /*Executor*/ Object executor, @NonNull /*RecognitionSupportCallback*/ Object supportListener) { Preconditions.checkArgument(recognizerIntent instanceof Intent); Preconditions.checkArgument(executor instanceof Executor); - Preconditions.checkArgument( - supportListener instanceof android.speech.RecognitionSupportCallback); - recognitionSupportExecutor = (Executor) executor; - recognitionSupportCallback = supportListener; + Preconditions.checkArgument(supportListener instanceof RecognitionSupportCallback); + + ShadowSpeechRecognizerState shadowState = getState(); + shadowState.recognitionSupportExecutor = (Executor) executor; + shadowState.recognitionSupportCallback = supportListener; } - @Implementation(minSdk = VERSION_CODES.TIRAMISU) - protected void triggerModelDownload(Intent recognizerIntent) { - latestModelDownloadIntent = recognizerIntent; + @RequiresApi(VERSION_CODES.TIRAMISU) + @Nullable + public Intent getLatestModelDownloadIntent() { + return getState().latestModelDownloadIntent; } - public static void setIsOnDeviceRecognitionAvailable(boolean available) { - isOnDeviceRecognitionAvailable = available; + @Implementation(minSdk = VERSION_CODES.TIRAMISU, maxSdk = U.SDK_INT) + protected void triggerModelDownload(Intent recognizerIntent) { + getState().latestModelDownloadIntent = recognizerIntent; } @RequiresApi(VERSION_CODES.TIRAMISU) public void triggerSupportResult(/*RecognitionSupport*/ Object recognitionSupport) { - Preconditions.checkArgument(recognitionSupport instanceof android.speech.RecognitionSupport); - recognitionSupportExecutor.execute( + Preconditions.checkArgument(recognitionSupport instanceof RecognitionSupport); + + ShadowSpeechRecognizerState shadowState = getState(); + shadowState.recognitionSupportExecutor.execute( () -> - ((android.speech.RecognitionSupportCallback) recognitionSupportCallback) - .onSupportResult((android.speech.RecognitionSupport) recognitionSupport)); + ((RecognitionSupportCallback) shadowState.recognitionSupportCallback) + .onSupportResult((RecognitionSupport) recognitionSupport)); } @RequiresApi(VERSION_CODES.TIRAMISU) public void triggerSupportError(int error) { - recognitionSupportExecutor.execute( - () -> - ((android.speech.RecognitionSupportCallback) recognitionSupportCallback) - .onError(error)); + ShadowSpeechRecognizerState shadowState = getState(); + shadowState.recognitionSupportExecutor.execute( + () -> ((RecognitionSupportCallback) shadowState.recognitionSupportCallback).onError(error)); } - @RequiresApi(VERSION_CODES.TIRAMISU) - @Nullable - public Intent getLatestModelDownloadIntent() { - return latestModelDownloadIntent; + /** + * {@link SpeechRecognizer} implementation now checks if the service's binder is alive whenever + * {@link SpeechRecognizer#checkOpenConnection} is called. This means that we need to return a + * deeper proxy that returns a delegating proxy that always reports the binder as alive. + */ + private static IRecognitionService createFakeSpeechRecognitionService() { + return ReflectionHelpers.createDelegatingProxy( + IRecognitionService.class, new AlwaysAliveSpeechRecognitionServiceDelegate()); + } + + /** + * A proxy delegate for {@link IRecognitionService} that always returns a delegating proxy that + * returns an {@link AlwaysAliveBinderDelegate} when {@link IRecognitionService#asBinder()} is + * called. + * + * @see #createFakeSpeechRecognitionService() for more details + */ + private static class AlwaysAliveSpeechRecognitionServiceDelegate { + public IBinder asBinder() { + return ReflectionHelpers.createDelegatingProxy( + IBinder.class, new AlwaysAliveBinderDelegate()); + } + } + + /** + * A proxy delegate for {@link IBinder} that always returns when {@link IBinder#isBinderAlive()} + * is called. + * + * @see #createFakeSpeechRecognitionService() for more details + */ + private static class AlwaysAliveBinderDelegate { + public boolean isBinderAlive() { + return true; + } + } + + /** + * The state of a specific instance of {@link ShadowSpeechRecognizer}. + * + * <p>NOTE: Not stored as variables in the parent class itself since subclasses may need to return + * a different instance of this class to operate on. + * + * <p>NOTE: This class is public since custom shadows may reside in a different package. + */ + public static class ShadowSpeechRecognizerState { + private boolean isRecognizerDestroyed = false; + private Intent recognizerIntent; + private RecognitionListener recognitionListener; + private Executor recognitionSupportExecutor; + private /*RecognitionSupportCallback*/ Object recognitionSupportCallback; + @Nullable private Intent latestModelDownloadIntent; + } + + /** + * An interface to access direct functions/variables of an instance of {@link SpeechRecognizer}. + * + * <p>Abstracted to allow subclasses to return customized accessors. + */ + protected interface ShadowSpeechRecognizerDirectAccessors { + /** + * Invokes {@link SpeechRecognizer#destroy()} on a real instance of {@link SpeechRecognizer}. + */ + void destroy(); + + /** Sets the {@link IRecognitionService} used by a real {@link SpeechRecognizer}. */ + void setService(IRecognitionService service); + + /** Returns a {@link Queue} of pending async tasks of a real {@link SpeechRecognizer}. */ + Queue<Message> getPendingTasks(); + + /** + * Returns the {@link Handler} of a real {@link SpeechRecognizer} that it uses to process any + * pending async tasks returned by {@link #getPendingTasks()}. + */ + Handler getHandler(); } /** Reflector interface for {@link SpeechRecognizer}'s internals. */ @ForType(SpeechRecognizer.class) - interface SpeechRecognizerReflector { + interface SpeechRecognizerReflector extends ShadowSpeechRecognizerDirectAccessors { @Static @Direct SpeechRecognizer createSpeechRecognizer(Context context, ComponentName serviceComponent); + @Static @Direct + SpeechRecognizer createOnDeviceSpeechRecognizer(Context context); + + @Direct + @Override void destroy(); @Accessor("mService") + @Override void setService(IRecognitionService service); @Accessor("mPendingTasks") + @Override Queue<Message> getPendingTasks(); @Accessor("mHandler") + @Override Handler getHandler(); - - @Static - @Direct - SpeechRecognizer createOnDeviceSpeechRecognizer(Context context); } } 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 b9f9a949f..02aa751c3 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java @@ -68,6 +68,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.HiddenApi; import org.robolectric.annotation.Implementation; @@ -180,6 +181,7 @@ public class ShadowTelephonyManager { private static Map<Integer, List<EmergencyNumber>> emergencyNumbersList; private static volatile boolean isDataRoamingEnabled; private /*CarrierRestrictionRules*/ Object carrierRestrictionRules; + private final AtomicInteger modemRebootCount = new AtomicInteger(); /** * Should be {@link TelephonyManager.BootstrapAuthenticationCallback} but this object was @@ -680,6 +682,12 @@ public class ShadowTelephonyManager { } } + private void checkModifyPhoneStatePermission() { + if (!checkPermission(permission.MODIFY_PHONE_STATE)) { + throw new SecurityException(); + } + } + static ShadowInstrumentation getShadowInstrumentation() { ActivityThread activityThread = (ActivityThread) RuntimeEnvironment.getActivityThread(); return Shadow.extract(activityThread.getInstrumentation()); @@ -1683,4 +1691,15 @@ public class ShadowTelephonyManager { protected /*CarrierRestrictionRules*/ Object getCarrierRestrictionRules() { return carrierRestrictionRules; } + + /** Implementation for {@link TelephonyManager#rebootModem} */ + @Implementation(minSdk = Build.VERSION_CODES.TIRAMISU) + protected void rebootModem() { + checkModifyPhoneStatePermission(); + modemRebootCount.incrementAndGet(); + } + + public int getModemRebootCount() { + return modemRebootCount.get(); + } } |