diff options
Diffstat (limited to 'shadows/framework/src')
56 files changed, 2747 insertions, 180 deletions
diff --git a/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java index 33276d916..33276d916 100755..100644 --- a/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java +++ b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java 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..e2b8f0df3 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/AssociationInfoBuilder.java @@ -0,0 +1,138 @@ +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) { + // We have two different constructors for AssociationInfo across + // T branches. aosp has the constructor that takes a new "revoked" parameter. + // Since there is not deterministic way to know which branch we are running in, + // we will reflect on the class to see if it has the mRevoked member. + // Based on the result we will either invoke the constructor with "revoked" or the + // one without this parameter. + if (ReflectionHelpers.hasField(AssociationInfo.class, "mRevoked")) { + 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 only supported in aosp*/), + 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(boolean.class, selfManaged), + ClassParameter.from(boolean.class, notifyOnDeviceNearby), + 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/CellIdentityNrBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellIdentityNrBuilder.java new file mode 100644 index 000000000..22a0e75c0 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellIdentityNrBuilder.java @@ -0,0 +1,135 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.telephony.CellIdentityNr; +import android.telephony.CellInfo; +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.CellIdentityNr}. */ +@RequiresApi(Build.VERSION_CODES.Q) +public class CellIdentityNrBuilder { + + private int pci = CellInfo.UNAVAILABLE; + private int tac = CellInfo.UNAVAILABLE; + private int nrarfcn = CellInfo.UNAVAILABLE; + private int[] bands = new int[0]; + @Nullable private String mcc = null; + @Nullable private String mnc = null; + private long nci = CellInfo.UNAVAILABLE; + @Nullable private String alphal = null; + @Nullable private String alphas = null; + private List<String> additionalPlmns = new ArrayList<>(); + + private CellIdentityNrBuilder() {} + + public static CellIdentityNrBuilder newBuilder() { + return new CellIdentityNrBuilder(); + } + + // An empty constructor is not available on Q. + @RequiresApi(Build.VERSION_CODES.R) + protected static CellIdentityNr getDefaultInstance() { + return reflector(CellIdentityNrReflector.class).newCellIdentityNr(); + } + + public CellIdentityNrBuilder setNci(long nci) { + this.nci = nci; + return this; + } + + public CellIdentityNrBuilder setPci(int pci) { + this.pci = pci; + return this; + } + + public CellIdentityNrBuilder setTac(int tac) { + this.tac = tac; + return this; + } + + public CellIdentityNrBuilder setNrarfcn(int nrarfcn) { + this.nrarfcn = nrarfcn; + return this; + } + + public CellIdentityNrBuilder setMcc(String mcc) { + this.mcc = mcc; + return this; + } + + public CellIdentityNrBuilder setMnc(String mnc) { + this.mnc = mnc; + return this; + } + + public CellIdentityNrBuilder setBands(int[] bands) { + this.bands = bands; + return this; + } + + public CellIdentityNrBuilder setLongOperatorName(String longOperatorName) { + this.alphal = longOperatorName; + return this; + } + + public CellIdentityNrBuilder setShortOperatorName(String shortOperatorName) { + this.alphas = shortOperatorName; + return this; + } + + public CellIdentityNrBuilder setAdditionalPlmns(List<String> additionalPlmns) { + this.additionalPlmns = additionalPlmns; + return this; + } + + public CellIdentityNr build() { + CellIdentityNrReflector cellIdentityReflector = reflector(CellIdentityNrReflector.class); + if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.R) { + return cellIdentityReflector.newCellIdentityNr( + pci, tac, nrarfcn, mcc, mnc, nci, alphal, alphas); + } else { + return cellIdentityReflector.newCellIdentityNr( + pci, tac, nrarfcn, bands, mcc, mnc, nci, alphal, alphas, additionalPlmns); + } + } + + @ForType(CellIdentityNr.class) + private interface CellIdentityNrReflector { + + @Constructor + CellIdentityNr newCellIdentityNr(); + + @Constructor + CellIdentityNr newCellIdentityNr( + int pci, + int tac, + int nrarfcn, + String mcc, + String mnc, + long nci, + String alphal, + String alphas); + + @Constructor + CellIdentityNr newCellIdentityNr( + int pci, + int tac, + int nrarfcn, + int[] bands, + String mcc, + String mnc, + long nci, + String alphal, + String alphas, + Collection<String> additionalPlmns); + } +} 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..6f3f93420 --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoLteBuilder.java @@ -0,0 +1,143 @@ +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() { + int apiLevel = RuntimeEnvironment.getApiLevel(); + if (cellIdentity == null) { + if (apiLevel > Build.VERSION_CODES.Q) { + cellIdentity = CellIdentityLteBuilder.getDefaultInstance(); + } else { + cellIdentity = CellIdentityLteBuilder.newBuilder().build(); + } + } + if (cellSignalStrength == null) { + cellSignalStrength = CellSignalStrengthLteBuilder.getDefaultInstance(); + } + 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/CellInfoNrBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoNrBuilder.java new file mode 100644 index 000000000..78195246e --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellInfoNrBuilder.java @@ -0,0 +1,93 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.os.Parcel; +import android.telephony.CellIdentityNr; +import android.telephony.CellInfoNr; +import android.telephony.CellSignalStrengthNr; +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.CellInfoNr}. */ +@RequiresApi(Build.VERSION_CODES.Q) +public class CellInfoNrBuilder { + + private boolean isRegistered = false; + private long timeStamp = 0L; + private int cellConnectionStatus = 0; + private CellIdentityNr cellIdentity; + private CellSignalStrengthNr cellSignalStrength; + + private CellInfoNrBuilder() {} + + public static CellInfoNrBuilder newBuilder() { + return new CellInfoNrBuilder(); + } + + public CellInfoNrBuilder setRegistered(boolean isRegistered) { + this.isRegistered = isRegistered; + return this; + } + + public CellInfoNrBuilder setTimeStampNanos(long timeStamp) { + this.timeStamp = timeStamp; + return this; + } + + public CellInfoNrBuilder setCellConnectionStatus(int cellConnectionStatus) { + this.cellConnectionStatus = cellConnectionStatus; + return this; + } + + public CellInfoNrBuilder setCellIdentity(CellIdentityNr cellIdentity) { + this.cellIdentity = cellIdentity; + return this; + } + + public CellInfoNrBuilder setCellSignalStrength(CellSignalStrengthNr cellSignalStrength) { + this.cellSignalStrength = cellSignalStrength; + return this; + } + + public CellInfoNr build() { + if (cellIdentity == null) { + cellIdentity = CellIdentityNrBuilder.getDefaultInstance(); + } + if (cellSignalStrength == null) { + cellSignalStrength = CellSignalStrengthNrBuilder.getDefaultInstance(); + } + // CellInfoNr has no default constructor below T so we write it to a Parcel. + if (RuntimeEnvironment.getApiLevel() <= Build.VERSION_CODES.TIRAMISU) { + Parcel p = Parcel.obtain(); + p.writeInt(/* CellInfo#TYPE_NR */ 6); + p.writeInt(isRegistered ? 1 : 0); + p.writeLong(timeStamp); + p.writeInt(cellConnectionStatus); + cellIdentity.writeToParcel(p, 0); + cellSignalStrength.writeToParcel(p, 0); + p.setDataPosition(0); + CellInfoNr cellInfoNr = CellInfoNr.CREATOR.createFromParcel(p); + p.recycle(); + return cellInfoNr; + } else { + return reflector(CellInfoNrReflector.class) + .newCellInfoNr( + cellConnectionStatus, isRegistered, timeStamp, cellIdentity, cellSignalStrength); + } + } + + @ForType(CellInfoNr.class) + private interface CellInfoNrReflector { + @Constructor + CellInfoNr newCellInfoNr( + int cellConnectionStatus, + boolean isRegistered, + long timeStamp, + CellIdentityNr cellIdentity, + CellSignalStrengthNr cellSignalStrength); + } +} 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/CellSignalStrengthNrBuilder.java b/shadows/framework/src/main/java/org/robolectric/shadows/CellSignalStrengthNrBuilder.java new file mode 100644 index 000000000..4f3f859bc --- /dev/null +++ b/shadows/framework/src/main/java/org/robolectric/shadows/CellSignalStrengthNrBuilder.java @@ -0,0 +1,140 @@ +package org.robolectric.shadows; + +import static org.robolectric.util.reflector.Reflector.reflector; + +import android.os.Build; +import android.telephony.CellInfo; +import android.telephony.CellSignalStrengthNr; +import androidx.annotation.RequiresApi; +import java.util.ArrayList; +import java.util.List; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.reflector.Constructor; +import org.robolectric.util.reflector.ForType; + +/** Builder for {@link android.telephony.CellSignalStrengthNr} */ +@RequiresApi(Build.VERSION_CODES.Q) +public class CellSignalStrengthNrBuilder { + + private int csiRrsp = CellInfo.UNAVAILABLE; + private int csiRsrq = CellInfo.UNAVAILABLE; + private int csiSinr = CellInfo.UNAVAILABLE; + private int csiCqiTableIndex = CellInfo.UNAVAILABLE; + private List<Byte> csiCqiReport = new ArrayList<>(); + private int ssRsrp = CellInfo.UNAVAILABLE; + private int ssRsrq = CellInfo.UNAVAILABLE; + private int ssSinr = CellInfo.UNAVAILABLE; + private int timingAdvance = CellInfo.UNAVAILABLE; + + private CellSignalStrengthNrBuilder() {} + + public static CellSignalStrengthNrBuilder newBuilder() { + return new CellSignalStrengthNrBuilder(); + } + + protected static CellSignalStrengthNr getDefaultInstance() { + return reflector(CellSignalStrengthNrReflector.class).newCellSignalStrengthNr(); + } + + public CellSignalStrengthNrBuilder setCsiRsrp(int csiRrsp) { + this.csiRrsp = csiRrsp; + return this; + } + + public CellSignalStrengthNrBuilder setCsiRsrq(int csiRsrq) { + this.csiRsrq = csiRsrq; + return this; + } + + public CellSignalStrengthNrBuilder setCsiSinr(int csiSinr) { + this.csiSinr = csiSinr; + return this; + } + + public CellSignalStrengthNrBuilder setCsiCqiTableIndex(int csiCqiTableIndex) { + this.csiCqiTableIndex = csiCqiTableIndex; + return this; + } + + public CellSignalStrengthNrBuilder setCsiCqiReport(List<Byte> csiCqiReport) { + this.csiCqiReport = csiCqiReport; + return this; + } + + public CellSignalStrengthNrBuilder setSsRsrp(int ssRsrp) { + this.ssRsrp = ssRsrp; + return this; + } + + public CellSignalStrengthNrBuilder setSsRsrq(int ssRsrq) { + this.ssRsrq = ssRsrq; + return this; + } + + public CellSignalStrengthNrBuilder setSsSinr(int ssSinr) { + this.ssSinr = ssSinr; + return this; + } + + public CellSignalStrengthNrBuilder setTimingAdvance(int timingAdvance) { + this.timingAdvance = timingAdvance; + return this; + } + + public CellSignalStrengthNr build() { + CellSignalStrengthNrReflector cellSignalStrengthReflector = + reflector(CellSignalStrengthNrReflector.class); + if (RuntimeEnvironment.getApiLevel() < Build.VERSION_CODES.TIRAMISU) { + return cellSignalStrengthReflector.newCellSignalStrengthNr( + csiRrsp, csiRsrq, csiSinr, ssRsrp, ssRsrq, ssSinr); + } else if (RuntimeEnvironment.getApiLevel() == Build.VERSION_CODES.TIRAMISU) { + return cellSignalStrengthReflector.newCellSignalStrengthNr( + csiRrsp, csiRsrq, csiSinr, csiCqiTableIndex, csiCqiReport, ssRsrp, ssRsrq, ssSinr); + } else { + return cellSignalStrengthReflector.newCellSignalStrengthNr( + csiRrsp, + csiRsrq, + csiSinr, + csiCqiTableIndex, + csiCqiReport, + ssRsrp, + ssRsrq, + ssSinr, + timingAdvance); + } + } + + @ForType(CellSignalStrengthNr.class) + private interface CellSignalStrengthNrReflector { + + @Constructor + CellSignalStrengthNr newCellSignalStrengthNr(); + + @Constructor + CellSignalStrengthNr newCellSignalStrengthNr( + int csRsrp, int csiRsrq, int csiSinr, int ssRsrp, int ssRsrq, int ssSinr); + + @Constructor + CellSignalStrengthNr newCellSignalStrengthNr( + int csRsrp, + int csiRsrq, + int csiSinr, + int csiCqiTableIndex, + List<Byte> csiCqiReport, + int ssRsrp, + int ssRsrq, + int ssSinr); + + @Constructor + CellSignalStrengthNr newCellSignalStrengthNr( + int csRsrp, + int csiRsrq, + int csiSinr, + int csiCqiTableIndex, + List<Byte> csiCqiReport, + int ssRsrp, + int ssRsrq, + int ssSinr, + 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/ShadowArscApkAssets9.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java index eb2276c9e..eb2276c9e 100755..100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscApkAssets9.java diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java index c0c9c7a8e..c0c9c7a8e 100755..100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowArscAssetManager.java 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/ShadowAudioManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java index 223435110..5f2d4fd3c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioManager.java @@ -93,6 +93,7 @@ public class ShadowAudioManager { private ImmutableList<Object> defaultDevicesForAttributes = ImmutableList.of(); private List<AudioDeviceInfo> inputDevices = new ArrayList<>(); private List<AudioDeviceInfo> outputDevices = new ArrayList<>(); + private List<AudioDeviceInfo> availableCommunicationDevices = new ArrayList<>(); private AudioDeviceInfo communicationDevice = null; public ShadowAudioManager() { @@ -451,6 +452,23 @@ public class ShadowAudioManager { } /** + * Sets the list of available communication devices represented by {@link AudioDeviceInfo}. + * + * <p>The previous list of communication devices is replaced and no notifications of the list of + * {@link AudioDeviceCallback} is done. + * + * <p>To add/remove devices one by one and trigger notifications for the list of {@link + * AudioDeviceCallback} please use one of the following methods {@link + * #addOutputDevice(AudioDeviceInfo, boolean)}, {@link #removeOutputDevice(AudioDeviceInfo, + * boolean)}. + */ + @TargetApi(VERSION_CODES.S) + public void setAvailableCommunicationDevices( + List<AudioDeviceInfo> availableCommunicationDevices) { + this.availableCommunicationDevices = new ArrayList<>(availableCommunicationDevices); + } + + /** * Adds an input {@link AudioDeviceInfo} and notifies the list of {@link AudioDeviceCallback} if * the device was not present before and indicated by {@code notifyAudioDeviceCallbacks}. */ @@ -497,6 +515,36 @@ public class ShadowAudioManager { } /** + * Adds an available communication {@link AudioDeviceInfo} and notifies the list of {@link + * AudioDeviceCallback} if the device was not present before and indicated by {@code + * notifyAudioDeviceCallbacks}. + */ + @TargetApi(VERSION_CODES.S) + public void addAvailableCommunicationDevice( + AudioDeviceInfo communicationDevice, boolean notifyAudioDeviceCallbacks) { + boolean changed = + !this.availableCommunicationDevices.contains(communicationDevice) + && this.availableCommunicationDevices.add(communicationDevice); + if (changed && notifyAudioDeviceCallbacks) { + notifyAudioDeviceCallbacks(ImmutableList.of(communicationDevice), /* added= */ true); + } + } + + /** + * Removes an available communication {@link AudioDeviceInfo} and notifies the list of {@link + * AudioDeviceCallback} if the device was present before and indicated by {@code + * notifyAudioDeviceCallbacks}. + */ + @TargetApi(VERSION_CODES.S) + public void removeAvailableCommunicationDevice( + AudioDeviceInfo communicationDevice, boolean notifyAudioDeviceCallbacks) { + boolean changed = this.availableCommunicationDevices.remove(communicationDevice); + if (changed && notifyAudioDeviceCallbacks) { + notifyAudioDeviceCallbacks(ImmutableList.of(communicationDevice), /* added= */ false); + } + } + + /** * Registers an {@link AudioDeviceCallback} object to receive notifications of changes to the set * of connected audio devices. * @@ -504,8 +552,10 @@ public class ShadowAudioManager { * * @see #addInputDevice(AudioDeviceInfo, boolean) * @see #addOutputDevice(AudioDeviceInfo, boolean) + * @see #addAvailableCommunicationDevice(AudioDeviceInfo, boolean) * @see #removeInputDevice(AudioDeviceInfo, boolean) * @see #removeOutputDevice(AudioDeviceInfo, boolean) + * @see #removeAvailableCommunicationDevice(AudioDeviceInfo, boolean) */ @Implementation(minSdk = M) protected void registerAudioDeviceCallback(AudioDeviceCallback callback, Handler handler) { @@ -520,8 +570,10 @@ public class ShadowAudioManager { * * @see #addInputDevice(AudioDeviceInfo, boolean) * @see #addOutputDevice(AudioDeviceInfo, boolean) + * @see #addAvailableCommunicationDevice(AudioDeviceInfo, boolean) * @see #removeInputDevice(AudioDeviceInfo, boolean) * @see #removeOutputDevice(AudioDeviceInfo, boolean) + * @see #removeAvailableCommunicationDevice(AudioDeviceInfo, boolean) */ @Implementation(minSdk = M) protected void unregisterAudioDeviceCallback(AudioDeviceCallback callback) { @@ -563,6 +615,11 @@ public class ShadowAudioManager { this.communicationDevice = null; } + @Implementation(minSdk = S) + protected List<AudioDeviceInfo> getAvailableCommunicationDevices() { + return availableCommunicationDevices; + } + @Implementation(minSdk = M) public AudioDeviceInfo[] getDevices(int flags) { List<AudioDeviceInfo> result = new ArrayList<>(); 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..75566ce68 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,11 +95,21 @@ 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) { - receiver.dispose(); } } @@ -141,24 +151,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 +237,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 +249,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 +290,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..1aa08b09a 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; @@ -28,7 +27,6 @@ import java.util.List; import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; import javax.annotation.Nullable; -import org.robolectric.RuntimeEnvironment; import org.robolectric.android.Bootstrap; import org.robolectric.annotation.HiddenApi; import org.robolectric.annotation.Implementation; @@ -88,20 +86,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/ShadowLauncherApps.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java index 4b28bafa2..e06f348bc 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLauncherApps.java @@ -49,6 +49,7 @@ import org.robolectric.util.reflector.ForType; public class ShadowLauncherApps { private List<ShortcutInfo> shortcuts = new ArrayList<>(); private final Multimap<UserHandle, String> enabledPackages = HashMultimap.create(); + private final Multimap<UserHandle, ComponentName> enabledActivities = HashMultimap.create(); private final Multimap<UserHandle, LauncherActivityInfo> shortcutActivityList = HashMultimap.create(); private final Multimap<UserHandle, LauncherActivityInfo> activityList = HashMultimap.create(); @@ -100,6 +101,17 @@ public class ShadowLauncherApps { } /** + * Sets an activity referenced by ComponentName as enabled, to be checked by {@link + * #isActivityEnabled(ComponentName, UserHandle)}. + * + * @param userHandle the user handle to be set. + * @param componentName the component name of the activity to be enabled. + */ + public void setActivityEnabled(UserHandle userHandle, ComponentName componentName) { + enabledActivities.put(userHandle, componentName); + } + + /** * Adds a {@link LauncherActivityInfo} to be retrieved by {@link * #getShortcutConfigActivityList(String, UserHandle)}. * @@ -219,10 +231,9 @@ public class ShadowLauncherApps { "This method is not currently supported in Robolectric."); } - @Implementation + @Implementation(minSdk = L) protected boolean isActivityEnabled(ComponentName component, UserHandle user) { - throw new UnsupportedOperationException( - "This method is not currently supported in Robolectric."); + return enabledActivities.containsEntry(user, component); } /** 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/ShadowLoadedApk.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java index 2a3a61b10..f954ac234 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowLoadedApk.java @@ -1,20 +1,38 @@ package org.robolectric.shadows; +import static org.robolectric.shadow.api.Shadow.newInstanceOf; +import static org.robolectric.util.reflector.Reflector.reflector; + import android.app.Application; import android.app.LoadedApk; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Resources; import android.os.Build.VERSION_CODES; +import org.robolectric.RuntimeEnvironment; 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; @Implements(value = LoadedApk.class, isInAndroidSdk = false) public class ShadowLoadedApk { + @RealObject private LoadedApk realLoadedApk; + private boolean isClassLoaderInitialized = false; + private final Object classLoaderLock = new Object(); @Implementation public ClassLoader getClassLoader() { + // The AppComponentFactory was introduced from SDK 28. + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.P) { + synchronized (classLoaderLock) { + if (!isClassLoaderInitialized) { + isClassLoaderInitialized = true; + tryInitAppComponentFactory(realLoadedApk); + } + } + } return this.getClass().getClassLoader(); } @@ -23,6 +41,35 @@ public class ShadowLoadedApk { return this.getClass().getClassLoader(); } + private void tryInitAppComponentFactory(LoadedApk realLoadedApk) { + if (RuntimeEnvironment.getApiLevel() >= VERSION_CODES.P) { + ApplicationInfo applicationInfo = realLoadedApk.getApplicationInfo(); + if (applicationInfo == null || applicationInfo.appComponentFactory == null) { + return; + } + _LoadedApk_ loadedApkReflector = reflector(_LoadedApk_.class, realLoadedApk); + if (!loadedApkReflector.getIncludeCode()) { + return; + } + String fullQualifiedClassName = + calculateFullQualifiedClassName( + applicationInfo.appComponentFactory, applicationInfo.packageName); + android.app.AppComponentFactory factory = + (android.app.AppComponentFactory) newInstanceOf(fullQualifiedClassName); + if (factory == null) { + factory = new android.app.AppComponentFactory(); + } + loadedApkReflector.setAppFactory(factory); + } + } + + private String calculateFullQualifiedClassName(String className, String packageName) { + if (packageName == null) { + return className; + } + return className.startsWith(".") ? packageName + className : className; + } + /** Accessor interface for {@link LoadedApk}'s internals. */ @ForType(LoadedApk.class) public interface _LoadedApk_ { @@ -32,5 +79,11 @@ public class ShadowLoadedApk { @Accessor("mResources") void setResources(Resources resources); + + @Accessor("mIncludeCode") + boolean getIncludeCode(); + + @Accessor("mAppComponentFactory") + void setAppFactory(Object appFactory); } } 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/ShadowPaint.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java index 8fec6464a..16a7398f7 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPaint.java @@ -112,6 +112,15 @@ public class ShadowPaint { } @Implementation + protected void setStrikeThruText(boolean strikeThruText) { + if (strikeThruText) { + setFlags(flags | Paint.STRIKE_THRU_TEXT_FLAG); + } else { + setFlags(flags & ~Paint.STRIKE_THRU_TEXT_FLAG); + } + } + + @Implementation protected Shader setShader(Shader shader) { this.shader = shader; return shader; 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..5caf01642 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 @@ -55,7 +59,16 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { invokeConstructor(MessageQueue.class, realQueue, from(boolean.class, quitAllowed)); int ptr = (int) nativeQueueRegistry.register(this); reflector(MessageQueueReflector.class, realQueue).setPtr(ptr); - clockListener = () -> nativeWake(ptr); + clockListener = + () -> { + synchronized (realQueue) { + // only wake up the Looper thread if queue is non empty to reduce contention if many + // Looper threads are active + if (getMessages() != null) { + nativeWake(ptr); + } + } + }; ShadowPausedSystemClock.addStaticListener(clockListener); } @@ -210,8 +223,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() { @@ -283,7 +316,9 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { return Duration.ZERO; } while (next != null) { - when = shadowOfMsg(next).getWhen(); + if (next.getTarget() != null) { + when = shadowOfMsg(next).getWhen(); + } next = shadowOfMsg(next).internalGetNext(); } } @@ -309,7 +344,9 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { synchronized (realQueue) { Message next = getMessages(); while (next != null) { - count++; + if (next.getTarget() != null) { + count++; + } next = shadowOfMsg(next).internalGetNext(); } } @@ -323,12 +360,24 @@ public class ShadowPausedMessageQueue extends ShadowMessageQueue { */ Message getNextIgnoringWhen() { synchronized (realQueue) { - Message head = getMessages(); - if (head != null) { - Message next = shadowOfMsg(head).internalGetNext(); - reflector(MessageQueueReflector.class, realQueue).setMessages(next); + Message prev = null; + Message msg = getMessages(); + // Head is blocked on synchronization barrier, find next asynchronous message. + if (msg != null && msg.getTarget() == null) { + do { + prev = msg; + msg = shadowOfMsg(msg).internalGetNext(); + } while (msg != null && !msg.isAsynchronous()); } - return head; + if (msg != null) { + Message next = shadowOfMsg(msg).internalGetNext(); + if (prev == null) { + reflector(MessageQueueReflector.class, realQueue).setMessages(next); + } else { + ReflectionHelpers.setField(prev, "next", next); + } + } + return msg; } } @@ -340,6 +389,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 +428,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..1b239d188 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSubscriptionManager.java @@ -21,6 +21,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.Executor; import org.robolectric.annotation.HiddenApi; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; @@ -282,6 +283,17 @@ public class ShadowSubscriptionManager { } /** + * Adds a listener to a local list of listeners. Will be triggered by {@link + * #setActiveSubscriptionInfoList} when the local list of {@link SubscriptionInfo} is updated. + */ + @Implementation(minSdk = R) + protected void addOnSubscriptionsChangedListener( + Executor executor, OnSubscriptionsChangedListener listener) { + listeners.add(listener); + listener.onSubscriptionsChanged(); + } + + /** * Removes a listener from a local list of listeners. Will be triggered by {@link * #setActiveSubscriptionInfoList} when the local list of {@link SubscriptionInfo} is updated. */ @@ -290,6 +302,16 @@ public class ShadowSubscriptionManager { listeners.remove(listener); } + /** + * Check if a listener exists in the {@link ShadowSubscriptionManager.listeners}. + * + * @param listener The listener to check. + * @return boolean True if the listener already added, otherwise false. + */ + public boolean hasOnSubscriptionsChangedListener(OnSubscriptionsChangedListener listener) { + return listeners.contains(listener); + } + /** Returns subscription Ids that were set via {@link #setActiveSubscriptionInfoList}. */ @Implementation(minSdk = LOLLIPOP_MR1) @HiddenApi @@ -405,6 +427,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/ShadowSystemVibrator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java index cce1990a0..41fbf4e7d 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSystemVibrator.java @@ -15,9 +15,9 @@ import android.media.AudioAttributes; import android.os.Handler; import android.os.Looper; import android.os.SystemVibrator; -import android.os.VibrationAttributes; import android.os.VibrationEffect; import android.os.vibrator.VibrationEffectSegment; +import com.google.common.base.Preconditions; import java.util.List; import java.util.Optional; import org.robolectric.RuntimeEnvironment; @@ -25,7 +25,8 @@ import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.util.ReflectionHelpers; -@Implements(value = SystemVibrator.class, isInAndroidSdk = false) +/** Shadow for {@link SystemVibrator}. */ +@Implements(value = SystemVibrator.class, isInAndroidSdk = false, looseSignatures = true) public class ShadowSystemVibrator extends ShadowVibrator { private final Handler handler = new Handler(Looper.getMainLooper()); @@ -133,11 +134,14 @@ public class ShadowSystemVibrator extends ShadowVibrator { @Implementation(minSdk = S) protected void vibrate( - int uid, - String opPkg, - VibrationEffect effect, - String reason, - VibrationAttributes attributes) { + Object uid, Object opPkg, Object effect, Object reason, Object attributes) { + Preconditions.checkArgument(uid instanceof Integer); + Preconditions.checkArgument(opPkg == null || opPkg instanceof String); + // The SystemVibrator#vibrate needs effect NonNull. + Preconditions.checkArgument(effect instanceof VibrationEffect); + Preconditions.checkArgument(reason == null || reason instanceof String); + // The SystemVibrator#vibrate needs attributes NonNull. + Preconditions.checkArgument(attributes instanceof android.os.VibrationAttributes); if (effect instanceof VibrationEffect.Composed) { VibrationEffect.Composed composedEffect = (VibrationEffect.Composed) effect; vibrationAttributesFromLastVibration = attributes; 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..048abf03b 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowTelephonyManager.java @@ -52,6 +52,7 @@ import android.telephony.TelephonyDisplayInfo; import android.telephony.TelephonyManager; import android.telephony.TelephonyManager.CellInfoCallback; import android.telephony.VisualVoicemailSmsFilterSettings; +import android.telephony.emergency.EmergencyNumber; import android.text.TextUtils; import android.util.SparseArray; import android.util.SparseIntArray; @@ -59,13 +60,16 @@ import com.google.common.base.Ascii; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; 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 +145,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; @@ -152,6 +157,7 @@ public class ShadowTelephonyManager { private VisualVoicemailSmsParams lastVisualVoicemailSmsParams; private VisualVoicemailSmsFilterSettings visualVoicemailSmsFilterSettings; private boolean emergencyCallbackMode; + private static Map<Integer, List<EmergencyNumber>> emergencyNumbersList; /** * Should be {@link TelephonyManager.BootstrapAuthenticationCallback} but this object was @@ -169,6 +175,7 @@ public class ShadowTelephonyManager { @Resetter public static void reset() { callComposerStatus = 0; + emergencyNumbersList = null; } @Implementation(minSdk = S) @@ -263,6 +270,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 +1229,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(); } /** @@ -1374,4 +1415,25 @@ public class ShadowTelephonyManager { return sentIntent; } } + + /** + * Sets the emergency numbers list returned by {@link TelephonyManager#getEmergencyNumberList}. + */ + public static void setEmergencyNumberList( + Map<Integer, List<EmergencyNumber>> emergencyNumbersList) { + ShadowTelephonyManager.emergencyNumbersList = emergencyNumbersList; + } + + /** + * Implementation for {@link TelephonyManager#getEmergencyNumberList}. + * + * @return an immutable map by default, unless set with {@link #setEmergencyNumberList}. + */ + @Implementation(minSdk = R) + protected Map<Integer, List<EmergencyNumber>> getEmergencyNumberList() { + if (ShadowTelephonyManager.emergencyNumbersList != null) { + return ShadowTelephonyManager.emergencyNumbersList; + } + return ImmutableMap.of(); + } } 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/ShadowVibrator.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java index b66a0a414..276a31db3 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowVibrator.java @@ -3,18 +3,19 @@ package org.robolectric.shadows; import static android.os.Build.VERSION_CODES.R; import android.media.AudioAttributes; -import android.os.VibrationAttributes; import android.os.VibrationEffect; import android.os.Vibrator; -import android.os.vibrator.VibrationEffectSegment; +import android.os.vibrator.PrimitiveSegment; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import javax.annotation.Nullable; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.annotation.Resetter; +import org.robolectric.util.ReflectionHelpers; @Implements(Vibrator.class) public class ShadowVibrator { @@ -22,10 +23,10 @@ public class ShadowVibrator { static boolean cancelled; static long milliseconds; protected static long[] pattern; - protected static final List<VibrationEffectSegment> vibrationEffectSegments = new ArrayList<>(); + protected static final List<Object> vibrationEffectSegments = new ArrayList<>(); protected static final List<PrimitiveEffect> primitiveEffects = new ArrayList<>(); protected static final List<Integer> supportedPrimitives = new ArrayList<>(); - @Nullable protected static VibrationAttributes vibrationAttributesFromLastVibration; + @Nullable protected static Object vibrationAttributesFromLastVibration; @Nullable protected static AudioAttributes audioAttributesFromLastVibration; static int repeat; static boolean hasVibrator = true; @@ -81,9 +82,18 @@ public class ShadowVibrator { return repeat; } - /** Returns the last list of {@link VibrationEffectSegment}. */ - public List<VibrationEffectSegment> getVibrationEffectSegments() { - return vibrationEffectSegments; + /** Returns the last list of {@link PrimitiveSegment} vibrations in {@link PrimitiveEffect}. */ + @SuppressWarnings("JdkCollectors") // toImmutableList is only supported in Java 8+. + public List<PrimitiveEffect> getPrimitiveSegmentsInPrimitiveEffects() { + return vibrationEffectSegments.stream() + .filter(segment -> segment instanceof PrimitiveSegment) + .map( + segment -> + new PrimitiveEffect( + ReflectionHelpers.getField(segment, "mPrimitiveId"), + ReflectionHelpers.getField(segment, "mScale"), + ReflectionHelpers.getField(segment, "mDelay"))) + .collect(Collectors.toList()); } /** Returns the last list of {@link PrimitiveEffect}. */ @@ -108,9 +118,9 @@ public class ShadowVibrator { supportedPrimitives.addAll(primitives); } - /** Returns the {@link VibrationAttributes} from the last vibration. */ + /** Returns the {@link android.os.VibrationAttributes} from the last vibration. */ @Nullable - public VibrationAttributes getVibrationAttributesFromLastVibration() { + public Object getVibrationAttributesFromLastVibration() { return vibrationAttributesFromLastVibration; } 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/ShadowWebView.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java index 706d86e10..1a8131f01 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowWebView.java @@ -569,7 +569,7 @@ public class ShadowWebView extends ShadowViewGroup { * * @param canGoBack Value to return from {@code android.webkit.WebView#canGoBack()} * @deprecated Do not depend on this method as it will be removed in a future update. The - * preferered method is to populate a fake web history to use for going back. + * preferred method is to populate a fake web history to use for going back. */ @Deprecated public void setCanGoBack(boolean canGoBack) { 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); + } + } + } } |