diff options
142 files changed, 12936 insertions, 1549 deletions
diff --git a/Android.bp b/Android.bp index 3c236706273a..516fc9c55222 100644 --- a/Android.bp +++ b/Android.bp @@ -64,7 +64,6 @@ filegroup { srcs: [ // Java/AIDL sources under frameworks/base ":framework-annotations", - ":ravenwood-annotations", ":framework-blobstore-sources", ":framework-core-sources", ":framework-drm-sources", @@ -296,7 +295,6 @@ java_defaults { enforce_permissions_exceptions: [ // Do not add entries to this list. ":framework-annotations", - ":ravenwood-annotations", ":framework-blobstore-sources", ":framework-core-sources", ":framework-drm-sources", diff --git a/core/api/module-lib-current.txt b/core/api/module-lib-current.txt index 8447a7feb54e..287e7874be41 100644 --- a/core/api/module-lib-current.txt +++ b/core/api/module-lib-current.txt @@ -604,6 +604,7 @@ package android.telephony { public class TelephonyManager { method @NonNull public static int[] getAllNetworkTypes(); + method @FlaggedApi("android.os.mainline_vcn_platform_api") @NonNull @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE) public java.util.Set<java.lang.String> getPackagesWithCarrierPrivileges(); } } diff --git a/core/java/android/hardware/camera2/CameraManager.java b/core/java/android/hardware/camera2/CameraManager.java index 1b21bdf7ba45..9e3a9b389b05 100644 --- a/core/java/android/hardware/camera2/CameraManager.java +++ b/core/java/android/hardware/camera2/CameraManager.java @@ -69,6 +69,7 @@ import android.os.RemoteException; import android.os.ServiceManager; import android.os.ServiceSpecificException; import android.os.SystemProperties; +import android.text.TextUtils; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; @@ -80,6 +81,7 @@ import com.android.internal.camera.flags.Flags; import com.android.internal.util.ArrayUtils; import java.lang.ref.WeakReference; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -2157,6 +2159,12 @@ public final class CameraManager { private final Set<Set<DeviceCameraInfo>> mConcurrentCameraIdCombinations = new ArraySet<>(); + // Diagnostic messages for ArrayIndexOutOfBoundsException in extractCameraIdListLocked + // b/367649718 + private static final int DEVICE_STATUS_ARRAY_SIZE = 10; + private final ArrayDeque<String> mDeviceStatusHistory = + new ArrayDeque<>(DEVICE_STATUS_ARRAY_SIZE); + // Registered availability callbacks and their executors private final ArrayMap<AvailabilityCallback, Executor> mCallbackMap = new ArrayMap<>(); @@ -2274,6 +2282,10 @@ public final class CameraManager { } try { + addDeviceStatusHistoryLocked(TextUtils.formatSimple( + "connectCameraServiceLocked(E): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); + CameraStatus[] cameraStatuses = cameraService.addListener(this); for (CameraStatus cameraStatus : cameraStatuses) { DeviceCameraInfo info = new DeviceCameraInfo(cameraStatus.cameraId, @@ -2296,6 +2308,10 @@ public final class CameraManager { } } mCameraService = cameraService; + + addDeviceStatusHistoryLocked(TextUtils.formatSimple( + "connectCameraServiceLocked(X): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); } catch (ServiceSpecificException e) { // Unexpected failure throw new IllegalStateException("Failed to register a camera service listener", e); @@ -2349,18 +2365,28 @@ public final class CameraManager { } private String[] extractCameraIdListLocked(int deviceId, int devicePolicy) { - List<String> cameraIds = new ArrayList<>(); - for (int i = 0; i < mDeviceStatus.size(); i++) { - int status = mDeviceStatus.valueAt(i); - DeviceCameraInfo info = mDeviceStatus.keyAt(i); - if (status == ICameraServiceListener.STATUS_NOT_PRESENT - || status == ICameraServiceListener.STATUS_ENUMERATING - || shouldHideCamera(deviceId, devicePolicy, info)) { - continue; + addDeviceStatusHistoryLocked(TextUtils.formatSimple( + "extractCameraIdListLocked(E): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); + try { + List<String> cameraIds = new ArrayList<>(); + for (int i = 0; i < mDeviceStatus.size(); i++) { + int status = mDeviceStatus.valueAt(i); + DeviceCameraInfo info = mDeviceStatus.keyAt(i); + if (status == ICameraServiceListener.STATUS_NOT_PRESENT + || status == ICameraServiceListener.STATUS_ENUMERATING + || shouldHideCamera(deviceId, devicePolicy, info)) { + continue; + } + cameraIds.add(info.mCameraId); } - cameraIds.add(info.mCameraId); + return cameraIds.toArray(new String[0]); + } catch (ArrayIndexOutOfBoundsException e) { + String message = e.getMessage(); + String messageWithHistory = message + ": {" + + String.join(" -> ", mDeviceStatusHistory) + "}"; + throw new ArrayIndexOutOfBoundsException(messageWithHistory); } - return cameraIds.toArray(new String[0]); } private Set<Set<String>> extractConcurrentCameraIdListLocked(int deviceId, @@ -2488,6 +2514,10 @@ public final class CameraManager { synchronized (mLock) { connectCameraServiceLocked(); try { + addDeviceStatusHistoryLocked(TextUtils.formatSimple( + "getCameraIdListNoLazy(E): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); + // The purpose of the addListener, removeListener pair here is to get a fresh // list of camera ids from cameraserver. We do this since for in test processes, // changes can happen w.r.t non-changeable permissions (eg: SYSTEM_CAMERA @@ -2521,6 +2551,9 @@ public final class CameraManager { onStatusChangedLocked(ICameraServiceListener.STATUS_NOT_PRESENT, info); mTorchStatus.remove(info); } + addDeviceStatusHistoryLocked(TextUtils.formatSimple( + "getCameraIdListNoLazy(X): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); } catch (ServiceSpecificException e) { // Unexpected failure throw new IllegalStateException("Failed to register a camera service listener", @@ -3209,7 +3242,13 @@ public final class CameraManager { public void onStatusChanged(int status, String cameraId, int deviceId) throws RemoteException { synchronized(mLock) { + addDeviceStatusHistoryLocked( + TextUtils.formatSimple("onStatusChanged(E): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); onStatusChangedLocked(status, new DeviceCameraInfo(cameraId, deviceId)); + addDeviceStatusHistoryLocked( + TextUtils.formatSimple("onStatusChanged(X): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); } } @@ -3352,6 +3391,10 @@ public final class CameraManager { */ public void binderDied() { synchronized(mLock) { + addDeviceStatusHistoryLocked( + TextUtils.formatSimple("binderDied(E): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); + // Only do this once per service death if (mCameraService == null) return; @@ -3380,6 +3423,10 @@ public final class CameraManager { mConcurrentCameraIdCombinations.clear(); scheduleCameraServiceReconnectionLocked(); + + addDeviceStatusHistoryLocked( + TextUtils.formatSimple("binderDied(X): tid(%d): mDeviceStatus size %d", + Thread.currentThread().getId(), mDeviceStatus.size())); } } @@ -3409,5 +3456,13 @@ public final class CameraManager { return Objects.hash(mCameraId, mDeviceId); } } + + private void addDeviceStatusHistoryLocked(String log) { + if (mDeviceStatusHistory.size() == DEVICE_STATUS_ARRAY_SIZE) { + mDeviceStatusHistory.removeFirst(); + } + mDeviceStatusHistory.addLast(log); + } + } // CameraManagerGlobal } // CameraManager diff --git a/core/java/android/security/attestationverification/AttestationVerificationManager.java b/core/java/android/security/attestationverification/AttestationVerificationManager.java index acf33822b3c7..ca4d417ad8fe 100644 --- a/core/java/android/security/attestationverification/AttestationVerificationManager.java +++ b/core/java/android/security/attestationverification/AttestationVerificationManager.java @@ -79,9 +79,7 @@ public class AttestationVerificationManager { * is also associated with a particular connection. * * <p>The {@code callback} is called with a result and {@link VerificationToken} (which may be - * null). The result is an integer (see constants in this class with the prefix {@code RESULT_}. - * The result is {@link #RESULT_SUCCESS} when at least one verifier has passed its checks. The - * token may be used in calls to other parts of the system. + * null). The result is an integer (see constants in {@link VerificationResultFlags}). * * <p>It's expected that a verifier will be able to decode and understand the passed values, * otherwise fail to verify. {@code attestation} should contain some type data to prevent parse @@ -108,7 +106,7 @@ public class AttestationVerificationManager { @NonNull Bundle requirements, @NonNull byte[] attestation, @NonNull @CallbackExecutor Executor executor, - @NonNull BiConsumer<@VerificationResult Integer, VerificationToken> callback) { + @NonNull BiConsumer<@VerificationResultFlags Integer, VerificationToken> callback) { try { AndroidFuture<IVerificationResult> resultCallback = new AndroidFuture<>(); resultCallback.thenAccept(result -> { @@ -155,7 +153,7 @@ public class AttestationVerificationManager { */ @RequiresPermission(Manifest.permission.USE_ATTESTATION_VERIFICATION_SERVICE) @CheckResult - @VerificationResult + @VerificationResultFlags public int verifyToken( @NonNull AttestationProfile profile, @LocalBindingType int localBindingType, @@ -280,30 +278,66 @@ public class AttestationVerificationManager { */ public static final int TYPE_CHALLENGE = 3; - /** @hide */ - @IntDef( - prefix = {"RESULT_"}, - value = { - RESULT_UNKNOWN, - RESULT_SUCCESS, - RESULT_FAILURE, - }) - @Retention(RetentionPolicy.SOURCE) + /** + * Verification result returned from {@link #verifyAttestation}. + * + * A value of {@code 0} indicates success. Otherwise, a bit flag is set from first failing stage + * below: + * <ol> + * <li> The received attestation's integrity (e.g. the certificate signatures) is validated. + * If this fails, {@link #FLAG_FAILURE_CERTS} will be returned with all other bits unset. + * <li> The local binding requirements are checked. If this fails, + * {@link #FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS} is returned with all other bits unset. + * <li> The profile requirements are checked. If this fails, a bit flag will be returned with + * some of the these bits set to indicate the type of profile requirement failure: + * {@link #FLAG_FAILURE_UNSUPPORTED_PROFILE}, {@link #FLAG_FAILURE_KEYSTORE_REQUIREMENTS}, + * {@link #FLAG_FAILURE_BOOT_STATE}, and {@link #FLAG_FAILURE_PATCH_LEVEL_DIFF}. + * </ol> + * + * Note: The reason of the failure must be not be provided to the remote device. + * + * @hide + */ + @IntDef(flag = true, prefix = {"FLAG_FAILURE_"}, + value = { + FLAG_FAILURE_UNKNOWN, + FLAG_FAILURE_UNSUPPORTED_PROFILE, + FLAG_FAILURE_CERTS, + FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS, + FLAG_FAILURE_KEYSTORE_REQUIREMENTS, + FLAG_FAILURE_BOOT_STATE, + FLAG_FAILURE_PATCH_LEVEL_DIFF, + }) @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) - public @interface VerificationResult { - } + @Retention(RetentionPolicy.SOURCE) + public @interface VerificationResultFlags{} - /** The result of the verification is unknown because it has a value unknown to this SDK. */ - public static final int RESULT_UNKNOWN = 0; + /** Flag: If there are unknown failures e.g. runtime exception. 0 = no, 1 = yes. */ + public static final int FLAG_FAILURE_UNKNOWN = 1; - /** The result of the verification was successful. */ - public static final int RESULT_SUCCESS = 1; + /** Flag: If the AVF profile is supported. 0 = supported, 1 = not supported */ + public static final int FLAG_FAILURE_UNSUPPORTED_PROFILE = 1 << 1; /** - * The result of the attestation verification was failure. The attestation could not be - * verified. + * Flag: Result bit for certs verification e.g. loading, generating, parsing certs. + * 0 = success, 1 = failure */ - public static final int RESULT_FAILURE = 2; + public static final int FLAG_FAILURE_CERTS = 1 << 2; + + /** Flag: Result bit for local binding requirements verification. 0 = success, 1 = failure. */ + public static final int FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS = 1 << 3; + + /** + * Flag: Result bit for KeyStore requirements verification. + * 0 = success, 1 = failure. + */ + public static final int FLAG_FAILURE_KEYSTORE_REQUIREMENTS = 1 << 4; + + /** Flag: Result bit for boot state verification. 0 = success, 1 = failure */ + public static final int FLAG_FAILURE_BOOT_STATE = 1 << 5; + + /** Flag: Result bit for patch level diff checks. 0 = success, 1 = failure. */ + public static final int FLAG_FAILURE_PATCH_LEVEL_DIFF = 1 << 6; /** * Requirements bundle parameter key for a public key, a byte array. @@ -351,26 +385,4 @@ public class AttestationVerificationManager { } return text + "(" + localBindingType + ")"; } - - /** @hide */ - public static String verificationResultCodeToString(@VerificationResult int resultCode) { - final String text; - switch (resultCode) { - case RESULT_UNKNOWN: - text = "UNKNOWN"; - break; - - case RESULT_SUCCESS: - text = "SUCCESS"; - break; - - case RESULT_FAILURE: - text = "FAILURE"; - break; - - default: - return Integer.toString(resultCode); - } - return text + "(" + resultCode + ")"; - } } diff --git a/core/java/android/security/attestationverification/AttestationVerificationService.java b/core/java/android/security/attestationverification/AttestationVerificationService.java index 26c3051f7f38..01668d729282 100644 --- a/core/java/android/security/attestationverification/AttestationVerificationService.java +++ b/core/java/android/security/attestationverification/AttestationVerificationService.java @@ -20,7 +20,7 @@ import android.annotation.CheckResult; import android.annotation.NonNull; import android.app.Service; import android.os.Bundle; -import android.security.attestationverification.AttestationVerificationManager.VerificationResult; +import android.security.attestationverification.AttestationVerificationManager.VerificationResultFlags; /** * A verifier which can be implemented by apps to verify an attestation (as described in {@link @@ -93,7 +93,7 @@ public abstract class AttestationVerificationService extends Service { * byte[], java.util.concurrent.Executor, java.util.function.BiConsumer) */ @CheckResult - @VerificationResult + @VerificationResultFlags public abstract int onVerifyPeerDeviceAttestation( @NonNull Bundle requirements, @NonNull byte[] attestation); diff --git a/core/java/android/security/attestationverification/VerificationToken.java b/core/java/android/security/attestationverification/VerificationToken.java index ae26823a9c7d..015010c52755 100644 --- a/core/java/android/security/attestationverification/VerificationToken.java +++ b/core/java/android/security/attestationverification/VerificationToken.java @@ -21,7 +21,7 @@ import android.os.Binder; import android.os.Bundle; import android.os.Parcelable; import android.security.attestationverification.AttestationVerificationManager.LocalBindingType; -import android.security.attestationverification.AttestationVerificationManager.VerificationResult; +import android.security.attestationverification.AttestationVerificationManager.VerificationResultFlags; import com.android.internal.util.DataClass; import com.android.internal.util.Parcelling; @@ -41,10 +41,6 @@ import java.util.function.BiConsumer; * @hide * @see Bundle#putParcelable(String, Parcelable) */ -@DataClass( - genConstructor = false, - genHiddenBuilder = true -) public final class VerificationToken implements Parcelable { /** @@ -69,17 +65,18 @@ public final class VerificationToken implements Parcelable { private final Bundle mRequirements; /** - * The result of the {@link AttestationVerificationManager#verifyAttestation(int, int, Bundle, - * byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token holders from - * accidentally reading this value without calling {@code verifyToken}. Do <b>not</b> use this - * value directly; call {@link AttestationVerificationManager#verifyToken(VerificationToken, - * Duration)} to verify a valid token and it will return this value. + * The result of the {@link AttestationVerificationManager#verifyAttestation(AttestationProfile, + * int, Bundle, byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token + * holders from accidentally reading this value without calling {@code verifyToken}. Do + * <b>not</b> use this value directly; call {@link AttestationVerificationManager#verifyToken( + * AttestationProfile, int, Bundle, VerificationToken, Duration)} to verify a valid token and it + * will return this value. * * If the token is valid, this value is returned directly by {#verifyToken}. * * @hide */ - @VerificationResult + @VerificationResultFlags private final int mVerificationResult; /** @@ -111,28 +108,13 @@ public final class VerificationToken implements Parcelable { private int mUid; - // Code below generated by codegen v1.0.23. - // - // DO NOT MODIFY! - // CHECKSTYLE:OFF Generated code - // - // To regenerate run: - // $ codegen $ANDROID_BUILD_TOP/frameworks/base/core/java/android/security/attestationverification/VerificationToken.java - // - // To exclude the generated code from IntelliJ auto-formatting enable (one-time): - // Settings > Editor > Code Style > Formatter Control - //@formatter:off - - - @DataClass.Generated.Member - /* package-private */ VerificationToken( + VerificationToken( @NonNull AttestationProfile attestationProfile, @LocalBindingType int localBindingType, @NonNull Bundle requirements, - @VerificationResult int verificationResult, + @VerificationResultFlags int verificationResult, @NonNull java.time.Instant verificationTime, - @NonNull byte[] hmac, - int uid) { + @NonNull byte[] hmac) { this.mAttestationProfile = attestationProfile; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mAttestationProfile); @@ -144,62 +126,61 @@ public final class VerificationToken implements Parcelable { NonNull.class, null, mRequirements); this.mVerificationResult = verificationResult; com.android.internal.util.AnnotationValidations.validate( - VerificationResult.class, null, mVerificationResult); + VerificationResultFlags.class, null, mVerificationResult); this.mVerificationTime = verificationTime; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mVerificationTime); this.mHmac = hmac; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mHmac); - this.mUid = uid; - - // onConstructed(); // You can define this method to get a callback } /** * The attestation profile which was used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @NonNull AttestationProfile getAttestationProfile() { return mAttestationProfile; } /** * The local binding type of the local binding data used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @LocalBindingType int getLocalBindingType() { return mLocalBindingType; } /** * The requirements used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @NonNull Bundle getRequirements() { return mRequirements; } /** - * The result of the {@link AttestationVerificationManager#verifyAttestation(int, int, Bundle, - * byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token holders from - * accidentally reading this value without calling {@code verifyToken}. Do <b>not</b> use this - * value directly; call {@link AttestationVerificationManager#verifyToken(VerificationToken, - * Duration)} to verify a valid token and it will return this value. + * The result of the {@link AttestationVerificationManager#verifyAttestation(AttestationProfile, + * int, Bundle, byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token + * holders from accidentally reading this value without calling {@code verifyToken}. Do + * <b>not</b> use this value directly; call {@link AttestationVerificationManager#verifyToken( + * AttestationProfile, int, Bundle, VerificationToken, Duration)} to verify a valid token and it + * will return this value. * * If the token is valid, this value is returned directly by {#verifyToken}. * * @hide */ - @DataClass.Generated.Member - public @VerificationResult int getVerificationResult() { + public @VerificationResultFlags int getVerificationResult() { return mVerificationResult; } /** * Time when the token was generated, set by the system. */ - @DataClass.Generated.Member public @NonNull java.time.Instant getVerificationTime() { return mVerificationTime; } @@ -212,25 +193,10 @@ public final class VerificationToken implements Parcelable { * * @hide */ - @DataClass.Generated.Member public @NonNull byte[] getHmac() { return mHmac; } - /** - * The UID of the process which called {@code verifyAttestation} to create the token, as - * returned by {@link Binder#getCallingUid()}. Calls to {@code verifyToken} will fail if the UID - * of calling process does not match this value. This ensures that tokens cannot be shared - * between UIDs. - * - * @hide - */ - @DataClass.Generated.Member - public int getUid() { - return mUid; - } - - @DataClass.Generated.Member static Parcelling<java.time.Instant> sParcellingForVerificationTime = Parcelling.Cache.get( ForInstant.class); @@ -242,7 +208,6 @@ public final class VerificationToken implements Parcelable { } @Override - @DataClass.Generated.Member public void writeToParcel(@NonNull android.os.Parcel dest, int flags) { // You can override field parcelling by defining methods like: // void parcelFieldName(Parcel dest, int flags) { ... } @@ -253,27 +218,24 @@ public final class VerificationToken implements Parcelable { dest.writeInt(mVerificationResult); sParcellingForVerificationTime.parcel(mVerificationTime, dest, flags); dest.writeByteArray(mHmac); - dest.writeInt(mUid); } @Override - @DataClass.Generated.Member public int describeContents() { return 0; } /** @hide */ @SuppressWarnings({"unchecked", "RedundantCast"}) - @DataClass.Generated.Member - /* package-private */ VerificationToken(@NonNull android.os.Parcel in) { + VerificationToken(@NonNull android.os.Parcel in) { // You can override field unparcelling by defining methods like: // static FieldType unparcelFieldName(Parcel in) { ... } - AttestationProfile attestationProfile = (AttestationProfile) in.readTypedObject(AttestationProfile.CREATOR); + AttestationProfile attestationProfile = (AttestationProfile) in.readTypedObject( + AttestationProfile.CREATOR); int localBindingType = in.readInt(); Bundle requirements = in.readBundle(); int verificationResult = in.readInt(); java.time.Instant verificationTime = sParcellingForVerificationTime.unparcel(in); byte[] hmac = in.createByteArray(); - int uid = in.readInt(); this.mAttestationProfile = attestationProfile; com.android.internal.util.AnnotationValidations.validate( @@ -286,19 +248,15 @@ public final class VerificationToken implements Parcelable { NonNull.class, null, mRequirements); this.mVerificationResult = verificationResult; com.android.internal.util.AnnotationValidations.validate( - VerificationResult.class, null, mVerificationResult); + VerificationResultFlags.class, null, mVerificationResult); this.mVerificationTime = verificationTime; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mVerificationTime); this.mHmac = hmac; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mHmac); - this.mUid = uid; - - // onConstructed(); // You can define this method to get a callback } - @DataClass.Generated.Member public static final @NonNull Parcelable.Creator<VerificationToken> CREATOR = new Parcelable.Creator<VerificationToken>() { @Override @@ -317,16 +275,14 @@ public final class VerificationToken implements Parcelable { * @hide */ @SuppressWarnings("WeakerAccess") - @DataClass.Generated.Member public static final class Builder { private @NonNull AttestationProfile mAttestationProfile; private @LocalBindingType int mLocalBindingType; private @NonNull Bundle mRequirements; - private @VerificationResult int mVerificationResult; + private @VerificationResultFlags int mVerificationResult; private @NonNull java.time.Instant mVerificationTime; private @NonNull byte[] mHmac; - private int mUid; private long mBuilderFieldsSet = 0L; @@ -340,34 +296,29 @@ public final class VerificationToken implements Parcelable { * @param requirements * The requirements used to perform the verification. * @param verificationResult - * The result of the {@link AttestationVerificationManager#verifyAttestation(int, int, Bundle, - * byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token holders from - * accidentally reading this value without calling {@code verifyToken}. Do <b>not</b> use this - * value directly; call {@link AttestationVerificationManager#verifyToken(VerificationToken, - * Duration)} to verify a valid token and it will return this value. + * The result of the {@link AttestationVerificationManager#verifyAttestation( + * AttestationProfile, int, Bundle, byte[], Executor, BiConsumer)} call. This value is + * kept hidden to prevent token holders from accidentally reading this value without + * calling {@code verifyToken}. Do <b>not</b> use this value directly; call {@link + * AttestationVerificationManager#verifyToken(AttestationProfile, int, Bundle, + * VerificationToken, Duration)} to verify a valid token and it will return this value. * * If the token is valid, this value is returned directly by {#verifyToken}. * @param verificationTime * Time when the token was generated, set by the system. * @param hmac - * A Hash-based message authentication code used to verify the contents and authenticity of the - * rest of the token. The hash is created using a secret key known only to the system server. - * When verifying the token, the system re-hashes the token and verifies the generated HMAC is - * the same. - * @param uid - * The UID of the process which called {@code verifyAttestation} to create the token, as - * returned by {@link Binder#getCallingUid()}. Calls to {@code verifyToken} will fail if the UID - * of calling process does not match this value. This ensures that tokens cannot be shared - * between UIDs. + * A Hash-based message authentication code used to verify the contents and authenticity + * of the rest of the token. The hash is created using a secret key known only to the + * system server. When verifying the token, the system re-hashes the token and verifies + * the generated HMAC is the same. */ public Builder( @NonNull AttestationProfile attestationProfile, @LocalBindingType int localBindingType, @NonNull Bundle requirements, - @VerificationResult int verificationResult, + @VerificationResultFlags int verificationResult, @NonNull java.time.Instant verificationTime, - @NonNull byte[] hmac, - int uid) { + @NonNull byte[] hmac) { mAttestationProfile = attestationProfile; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mAttestationProfile); @@ -379,20 +330,20 @@ public final class VerificationToken implements Parcelable { NonNull.class, null, mRequirements); mVerificationResult = verificationResult; com.android.internal.util.AnnotationValidations.validate( - VerificationResult.class, null, mVerificationResult); + VerificationResultFlags.class, null, mVerificationResult); mVerificationTime = verificationTime; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mVerificationTime); mHmac = hmac; com.android.internal.util.AnnotationValidations.validate( NonNull.class, null, mHmac); - mUid = uid; } /** * The attestation profile which was used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @NonNull Builder setAttestationProfile(@NonNull AttestationProfile value) { checkNotUsed(); mBuilderFieldsSet |= 0x1; @@ -402,8 +353,9 @@ public final class VerificationToken implements Parcelable { /** * The local binding type of the local binding data used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @NonNull Builder setLocalBindingType(@LocalBindingType int value) { checkNotUsed(); mBuilderFieldsSet |= 0x2; @@ -413,8 +365,9 @@ public final class VerificationToken implements Parcelable { /** * The requirements used to perform the verification. + * + * @hide */ - @DataClass.Generated.Member public @NonNull Builder setRequirements(@NonNull Bundle value) { checkNotUsed(); mBuilderFieldsSet |= 0x4; @@ -423,18 +376,18 @@ public final class VerificationToken implements Parcelable { } /** - * The result of the {@link AttestationVerificationManager#verifyAttestation(int, int, Bundle, - * byte[], Executor, BiConsumer)} call. This value is kept hidden to prevent token holders from - * accidentally reading this value without calling {@code verifyToken}. Do <b>not</b> use this - * value directly; call {@link AttestationVerificationManager#verifyToken(VerificationToken, - * Duration)} to verify a valid token and it will return this value. + * The result of the {@link AttestationVerificationManager#verifyAttestation( + * AttestationProfile, int, Bundle, byte[], Executor, BiConsumer)} call. This value is kept + * hidden to prevent token holders from accidentally reading this value without calling + * {@code verifyToken}. Do <b>not</b> use this value directly; call {@link + * AttestationVerificationManager#verifyToken(AttestationProfile, int, Bundle, + * VerificationToken, Duration)} to verify a valid token and it will return this value. * * If the token is valid, this value is returned directly by {#verifyToken}. * * @hide */ - @DataClass.Generated.Member - public @NonNull Builder setVerificationResult(@VerificationResult int value) { + public @NonNull Builder setVerificationResult(@VerificationResultFlags int value) { checkNotUsed(); mBuilderFieldsSet |= 0x8; mVerificationResult = value; @@ -444,7 +397,6 @@ public final class VerificationToken implements Parcelable { /** * Time when the token was generated, set by the system. */ - @DataClass.Generated.Member public @NonNull Builder setVerificationTime(@NonNull java.time.Instant value) { checkNotUsed(); mBuilderFieldsSet |= 0x10; @@ -453,14 +405,13 @@ public final class VerificationToken implements Parcelable { } /** - * A Hash-based message authentication code used to verify the contents and authenticity of the - * rest of the token. The hash is created using a secret key known only to the system server. - * When verifying the token, the system re-hashes the token and verifies the generated HMAC is - * the same. + * A Hash-based message authentication code used to verify the contents and authenticity of + * the rest of the token. The hash is created using a secret key known only to the system + * server. When verifying the token, the system re-hashes the token and verifies the + * generated HMAC is the same. * * @hide */ - @DataClass.Generated.Member public @NonNull Builder setHmac(@NonNull byte... value) { checkNotUsed(); mBuilderFieldsSet |= 0x20; @@ -468,26 +419,10 @@ public final class VerificationToken implements Parcelable { return this; } - /** - * The UID of the process which called {@code verifyAttestation} to create the token, as - * returned by {@link Binder#getCallingUid()}. Calls to {@code verifyToken} will fail if the UID - * of calling process does not match this value. This ensures that tokens cannot be shared - * between UIDs. - * - * @hide - */ - @DataClass.Generated.Member - public @NonNull Builder setUid(int value) { - checkNotUsed(); - mBuilderFieldsSet |= 0x40; - mUid = value; - return this; - } - /** Builds the instance. This builder should not be touched after calling this! */ public @NonNull VerificationToken build() { checkNotUsed(); - mBuilderFieldsSet |= 0x80; // Mark builder used + mBuilderFieldsSet |= 0x40; // Mark builder used VerificationToken o = new VerificationToken( mAttestationProfile, @@ -495,29 +430,15 @@ public final class VerificationToken implements Parcelable { mRequirements, mVerificationResult, mVerificationTime, - mHmac, - mUid); + mHmac); return o; } private void checkNotUsed() { - if ((mBuilderFieldsSet & 0x80) != 0) { + if ((mBuilderFieldsSet & 0x40) != 0) { throw new IllegalStateException( "This Builder should not be reused. Use a new Builder instance instead"); } } } - - @DataClass.Generated( - time = 1633629747234L, - codegenVersion = "1.0.23", - sourceFile = "frameworks/base/core/java/android/security/attestationverification/VerificationToken.java", - inputSignatures = "private final @android.annotation.NonNull android.security.attestationverification.AttestationProfile mAttestationProfile\nprivate final @android.security.attestationverification.AttestationVerificationManager.LocalBindingType int mLocalBindingType\nprivate final @android.annotation.NonNull android.os.Bundle mRequirements\nprivate final @android.security.attestationverification.AttestationVerificationManager.VerificationResult int mVerificationResult\nprivate final @android.annotation.NonNull @com.android.internal.util.DataClass.ParcelWith(com.android.internal.util.Parcelling.BuiltIn.ForInstant.class) java.time.Instant mVerificationTime\nprivate final @android.annotation.NonNull byte[] mHmac\nprivate int mUid\nclass VerificationToken extends java.lang.Object implements [android.os.Parcelable]\n@com.android.internal.util.DataClass(genConstructor=false, genHiddenBuilder=true)") - @Deprecated - private void __metadata() {} - - - //@formatter:on - // End of generated code - } diff --git a/core/java/android/view/accessibility/flags/accessibility_flags.aconfig b/core/java/android/view/accessibility/flags/accessibility_flags.aconfig index 513587eacfa7..b9e97502cad7 100644 --- a/core/java/android/view/accessibility/flags/accessibility_flags.aconfig +++ b/core/java/android/view/accessibility/flags/accessibility_flags.aconfig @@ -4,6 +4,13 @@ container: "system" # NOTE: Keep alphabetized to help limit merge conflicts from multiple simultaneous editors. flag { + name: "a11y_expansion_state_api" + namespace: "accessibility" + description: "Enables new APIs for an app to convey if a node is expanded or collapsed." + bug: "362782536" +} + +flag { name: "a11y_overlay_callbacks" is_exported: true namespace: "accessibility" diff --git a/core/java/android/view/autofill/AutofillStateFingerprint.java b/core/java/android/view/autofill/AutofillStateFingerprint.java index 2db4285f0820..7f3858e842ed 100644 --- a/core/java/android/view/autofill/AutofillStateFingerprint.java +++ b/core/java/android/view/autofill/AutofillStateFingerprint.java @@ -97,7 +97,6 @@ public final class AutofillStateFingerprint { if (sDebug) { Log.d(TAG, "Autofillable views count prior to auth:" + autofillableViews.size()); } -// ArrayList<Integer> hashes = getFingerprintIds(autofillableViews); ArrayMap<Integer, View> hashes = getFingerprintIds(autofillableViews); for (Map.Entry<Integer, View> entry : hashes.entrySet()) { @@ -123,7 +122,6 @@ public final class AutofillStateFingerprint { if (view != null) { int id = getEphemeralFingerprintId(view, 0 /* position irrelevant */); AutofillId autofillId = view.getAutofillId(); - autofillId.setSessionId(mSessionId); mHashToAutofillIdMap.put(id, autofillId); } else { if (sDebug) { diff --git a/core/java/com/android/internal/protolog/ProtoLogGroup.java b/core/java/com/android/internal/protolog/ProtoLogGroup.java new file mode 100644 index 000000000000..65218702110a --- /dev/null +++ b/core/java/com/android/internal/protolog/ProtoLogGroup.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.protolog; + +import android.annotation.NonNull; + +import com.android.internal.protolog.common.IProtoLogGroup; + +public class ProtoLogGroup implements IProtoLogGroup { + + /** The name should be unique across the codebase. */ + @NonNull + private final String mName; + @NonNull + private final String mTag; + private final boolean mEnabled; + private boolean mLogToProto; + private boolean mLogToLogcat; + + public ProtoLogGroup(@NonNull String name) { + this(name, name); + } + + public ProtoLogGroup(@NonNull String name, @NonNull String tag) { + this(name, tag, true); + } + + public ProtoLogGroup(@NonNull String name, @NonNull String tag, boolean enabled) { + mName = name; + mTag = tag; + mEnabled = enabled; + mLogToProto = enabled; + mLogToLogcat = enabled; + } + + @Override + public boolean isEnabled() { + return mEnabled; + } + + @Deprecated + @Override + public boolean isLogToProto() { + return mLogToProto; + } + + @Override + public boolean isLogToLogcat() { + return mLogToLogcat; + } + + @Override + @NonNull + public String getTag() { + return mTag; + } + + @Deprecated + @Override + public void setLogToProto(boolean logToProto) { + mLogToProto = logToProto; + } + + @Override + public void setLogToLogcat(boolean logToLogcat) { + mLogToLogcat = logToLogcat; + } + + @Override + @NonNull + public String name() { + return mName; + } + + @Override + public int getId() { + return mName.hashCode(); + } +} diff --git a/core/jni/android_database_SQLiteConnection.cpp b/core/jni/android_database_SQLiteConnection.cpp index 3370f386f4d2..ba7e70564143 100644 --- a/core/jni/android_database_SQLiteConnection.cpp +++ b/core/jni/android_database_SQLiteConnection.cpp @@ -16,27 +16,22 @@ #define LOG_TAG "SQLiteConnection" -#include <jni.h> -#include <nativehelper/JNIHelp.h> +#include <android-base/mapped_file.h> #include <android_runtime/AndroidRuntime.h> #include <android_runtime/Log.h> - -#include <utils/Log.h> -#include <utils/String8.h> -#include <utils/String16.h> -#include <cutils/ashmem.h> -#include <sys/mman.h> - -#include <string.h> -#include <unistd.h> - #include <androidfw/CursorWindow.h> - +#include <cutils/ashmem.h> +#include <jni.h> +#include <nativehelper/JNIHelp.h> #include <sqlite3.h> #include <sqlite3_android.h> +#include <string.h> +#include <unistd.h> +#include <utils/Log.h> +#include <utils/String16.h> +#include <utils/String8.h> #include "android_database_SQLiteCommon.h" - #include "core_jni_helpers.h" // Set to 1 to use UTF16 storage for localized indexes. @@ -669,13 +664,14 @@ static int createAshmemRegionWithData(JNIEnv* env, const void* data, size_t leng ALOGE("ashmem_create_region failed: %s", strerror(error)); } else { if (length > 0) { - void* ptr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (ptr == MAP_FAILED) { + std::unique_ptr<base::MappedFile> mappedFile = + base::MappedFile::FromFd(fd, 0, length, PROT_READ | PROT_WRITE); + if (mappedFile == nullptr) { error = errno; ALOGE("mmap failed: %s", strerror(error)); } else { - memcpy(ptr, data, length); - munmap(ptr, length); + memcpy(mappedFile->data(), data, length); + mappedFile.reset(); } } diff --git a/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml b/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml new file mode 100644 index 000000000000..5260450e8a13 --- /dev/null +++ b/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2024 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M240,840L240,720L120,720L120,640L320,640L320,840L240,840ZM640,840L640,640L840,640L840,720L720,720L720,840L640,840ZM120,320L120,240L240,240L240,120L320,120L320,320L120,320ZM640,320L640,120L720,120L720,240L840,240L840,320L640,320Z"/> +</vector> diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt index 05ce36120c4f..71bcb590ae23 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt @@ -20,10 +20,11 @@ package com.android.wm.shell.apptoweb import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.net.Uri -private val browserIntent = Intent() +private val GenericBrowserIntent = Intent() .setAction(Intent.ACTION_VIEW) .addCategory(Intent.CATEGORY_BROWSABLE) .setData(Uri.parse("http:")) @@ -32,9 +33,9 @@ private val browserIntent = Intent() * Returns a boolean indicating whether a given package is a browser app. */ fun isBrowserApp(context: Context, packageName: String, userId: Int): Boolean { - browserIntent.setPackage(packageName) + GenericBrowserIntent.setPackage(packageName) val list = context.packageManager.queryIntentActivitiesAsUser( - browserIntent, PackageManager.MATCH_ALL, userId + GenericBrowserIntent, PackageManager.MATCH_ALL, userId ) list.forEach { @@ -44,3 +45,17 @@ fun isBrowserApp(context: Context, packageName: String, userId: Int): Boolean { } return false } + +/** + * Returns intent if there is a browser application available to handle the uri. Otherwise, returns + * null. + */ +fun getBrowserIntent(uri: Uri, packageManager: PackageManager): Intent? { + val intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER) + .setData(uri) + .addFlags(FLAG_ACTIVITY_NEW_TASK) + // If there is no browser application available to handle intent, return null + val component = intent.resolveActivity(packageManager) ?: return null + intent.setComponent(component) + return intent +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java index 3e5adf395cdd..5836085e0ddc 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java @@ -1501,10 +1501,6 @@ public class BackAnimationController implements RemoteCallable<BackAnimationCont int rootIdx = -1; for (int i = info.getChanges().size() - 1; i >= 0; --i) { final TransitionInfo.Change c = info.getChanges().get(i); - if (c.hasFlags(FLAG_IS_WALLPAPER)) { - st.setAlpha(c.getLeash(), 1.0f); - continue; - } if (TransitionUtil.isOpeningMode(c.getMode())) { final Point offset = c.getEndRelOffset(); st.setPosition(c.getLeash(), offset.x, offset.y); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java index 2c030591e106..41a2ee67e8ae 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java @@ -240,6 +240,7 @@ public abstract class WMShellModule { IWindowManager windowManager, ShellCommandHandler shellCommandHandler, ShellTaskOrganizer taskOrganizer, + @DynamicOverride DesktopModeTaskRepository desktopRepository, DisplayController displayController, ShellController shellController, DisplayInsetsController displayInsetsController, @@ -266,6 +267,7 @@ public abstract class WMShellModule { shellCommandHandler, windowManager, taskOrganizer, + desktopRepository, displayController, shellController, displayInsetsController, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt index 0e8c4e70e05d..955fe83d34ee 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt @@ -60,6 +60,7 @@ class DesktopModeTaskRepository ( * @property minimizedTasks task ids for active freeform tasks that are currently minimized. * @property closingTasks task ids for tasks that are going to close, but are currently visible. * @property freeformTasksInZOrder list of current freeform task ids ordered from top to bottom + * @property fullImmersiveTaskId the task id of the desktop task that is in full-immersive mode. * (top is at index 0). */ private data class DesktopTaskData( @@ -69,13 +70,15 @@ class DesktopModeTaskRepository ( // TODO(b/332682201): Remove when the repository state is updated via TransitionObserver val closingTasks: ArraySet<Int> = ArraySet(), val freeformTasksInZOrder: ArrayList<Int> = ArrayList(), + var fullImmersiveTaskId: Int? = null, ) { fun deepCopy(): DesktopTaskData = DesktopTaskData( activeTasks = ArraySet(activeTasks), visibleTasks = ArraySet(visibleTasks), minimizedTasks = ArraySet(minimizedTasks), closingTasks = ArraySet(closingTasks), - freeformTasksInZOrder = ArrayList(freeformTasksInZOrder) + freeformTasksInZOrder = ArrayList(freeformTasksInZOrder), + fullImmersiveTaskId = fullImmersiveTaskId ) } @@ -300,6 +303,23 @@ class DesktopModeTaskRepository ( } } + /** Set whether the given task is the full-immersive task in this display. */ + fun setTaskInFullImmersiveState(displayId: Int, taskId: Int, immersive: Boolean) { + val desktopData = desktopTaskDataByDisplayId.getOrCreate(displayId) + if (immersive) { + desktopData.fullImmersiveTaskId = taskId + } else { + if (desktopData.fullImmersiveTaskId == taskId) { + desktopData.fullImmersiveTaskId = null + } + } + } + + /* Whether the task is in full-immersive state. */ + fun isTaskInFullImmersiveState(taskId: Int): Boolean { + return desktopTaskDataSequence().any { taskId == it.fullImmersiveTaskId } + } + private fun notifyVisibleTaskListeners(displayId: Int, visibleTasksCount: Int) { visibleTasksListeners.forEach { (listener, executor) -> executor.execute { listener.onTasksVisibilityChanged(displayId, visibleTasksCount) } diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt index acaad69e91b6..125805c14321 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt @@ -209,7 +209,7 @@ class DesktopTasksController( { createExternalInterface() }, this ) - shellController.addUserChangeListener(this); + shellController.addUserChangeListener(this) transitions.addHandler(this) dragToDesktopTransitionHandler.dragToDesktopStateListener = dragToDesktopStateListener recentsTransitionHandler.addTransitionStateListener( diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java b/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java index 6e084d6e05a4..27472493a8bc 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java @@ -146,6 +146,11 @@ public class TaskSnapshotWindow { Slog.w(TAG, "Failed to relayout snapshot starting window"); return null; } + if (!surfaceControl.isValid()) { + snapshotSurface.clearWindowSynced(); + Slog.w(TAG, "Unable to draw snapshot, no valid surface"); + return null; + } SnapshotDrawerUtils.drawSnapshotOnSurface(info, layoutParams, surfaceControl, snapshot, info.taskBounds, topWindowInsetsState, true /* releaseAfterDraw */); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java index 2f5059f3161c..399e39a920fc 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java @@ -17,13 +17,13 @@ package com.android.wm.shell.transition; import static android.view.Display.INVALID_DISPLAY; +import static android.window.TransitionInfo.FLAG_IS_DISPLAY; import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP; import static com.android.window.flags.Flags.enableDisplayFocusInShellTransitions; import static com.android.wm.shell.transition.Transitions.TransitionObserver; import android.annotation.NonNull; -import android.app.ActivityManager.RunningTaskInfo; import android.os.IBinder; import android.os.RemoteException; import android.util.Slog; @@ -62,10 +62,9 @@ public class FocusTransitionObserver implements TransitionObserver { final List<TransitionInfo.Change> changes = info.getChanges(); for (int i = changes.size() - 1; i >= 0; i--) { final TransitionInfo.Change change = changes.get(i); - final RunningTaskInfo task = change.getTaskInfo(); - if (task != null && task.isFocused && change.hasFlags(FLAG_MOVED_TO_TOP)) { - if (mFocusedDisplayId != task.displayId) { - mFocusedDisplayId = task.displayId; + if (change.hasFlags(FLAG_IS_DISPLAY) && change.hasFlags(FLAG_MOVED_TO_TOP)) { + if (mFocusedDisplayId != change.getEndDisplayId()) { + mFocusedDisplayId = change.getEndDisplayId(); notifyFocusedDisplayChanged(); } return; diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java index 05065be7171c..839973fcbdd5 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java @@ -194,6 +194,8 @@ public class CaptionWindowDecoration extends WindowDecoration<WindowDecorLinearL ActivityManager.RunningTaskInfo taskInfo, boolean applyStartTransactionOnDraw, boolean setTaskCropAndPosition, + boolean isStatusBarVisible, + boolean isKeyguardVisibleAndOccluded, InsetsState displayInsetsState) { relayoutParams.reset(); relayoutParams.mRunningTaskInfo = taskInfo; @@ -204,6 +206,8 @@ public class CaptionWindowDecoration extends WindowDecoration<WindowDecorLinearL : R.dimen.freeform_decor_shadow_unfocused_thickness; relayoutParams.mApplyStartTransactionOnDraw = applyStartTransactionOnDraw; relayoutParams.mSetTaskPositionAndCrop = setTaskCropAndPosition; + relayoutParams.mIsCaptionVisible = taskInfo.isFreeform() + || (isStatusBarVisible && !isKeyguardVisibleAndOccluded); if (TaskInfoKt.isTransparentCaptionBarAppearance(taskInfo)) { // If the app is requesting to customize the caption bar, allow input to fall @@ -240,7 +244,8 @@ public class CaptionWindowDecoration extends WindowDecoration<WindowDecorLinearL final WindowContainerTransaction wct = new WindowContainerTransaction(); updateRelayoutParams(mRelayoutParams, taskInfo, applyStartTransactionOnDraw, - setTaskCropAndPosition, mDisplayController.getInsetsState(taskInfo.displayId)); + setTaskCropAndPosition, mIsStatusBarVisible, mIsKeyguardVisibleAndOccluded, + mDisplayController.getInsetsState(taskInfo.displayId)); relayout(mRelayoutParams, startT, finishT, wct, oldRootView, mResult); // After this line, mTaskInfo is up-to-date and should be used instead of taskInfo diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java index c34a0bc829c4..3330f968332c 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java @@ -22,9 +22,6 @@ import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM; import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN; import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW; import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED; -import static android.content.Intent.ACTION_MAIN; -import static android.content.Intent.CATEGORY_APP_BROWSER; -import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.view.InputDevice.SOURCE_TOUCHSCREEN; import static android.view.MotionEvent.ACTION_CANCEL; import static android.view.MotionEvent.ACTION_HOVER_ENTER; @@ -58,7 +55,6 @@ import android.graphics.PointF; import android.graphics.Rect; import android.graphics.Region; import android.hardware.input.InputManager; -import android.net.Uri; import android.os.Handler; import android.os.IBinder; import android.os.Looper; @@ -107,6 +103,7 @@ import com.android.wm.shell.common.MultiInstanceHelper; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SyncTransactionQueue; import com.android.wm.shell.desktopmode.DesktopActivityOrientationChangeHandler; +import com.android.wm.shell.desktopmode.DesktopModeTaskRepository; import com.android.wm.shell.desktopmode.DesktopModeVisualIndicator; import com.android.wm.shell.desktopmode.DesktopTasksController; import com.android.wm.shell.desktopmode.DesktopTasksController.SnapPosition; @@ -158,6 +155,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { private final ActivityTaskManager mActivityTaskManager; private final ShellCommandHandler mShellCommandHandler; private final ShellTaskOrganizer mTaskOrganizer; + private final DesktopModeTaskRepository mDesktopRepository; private final ShellController mShellController; private final Context mContext; private final @ShellMainThread Handler mMainHandler; @@ -229,6 +227,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { ShellCommandHandler shellCommandHandler, IWindowManager windowManager, ShellTaskOrganizer taskOrganizer, + DesktopModeTaskRepository desktopRepository, DisplayController displayController, ShellController shellController, DisplayInsetsController displayInsetsController, @@ -254,6 +253,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { shellCommandHandler, windowManager, taskOrganizer, + desktopRepository, displayController, shellController, displayInsetsController, @@ -288,6 +288,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { ShellCommandHandler shellCommandHandler, IWindowManager windowManager, ShellTaskOrganizer taskOrganizer, + DesktopModeTaskRepository desktopRepository, DisplayController displayController, ShellController shellController, DisplayInsetsController displayInsetsController, @@ -316,6 +317,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { mBgExecutor = bgExecutor; mActivityTaskManager = mContext.getSystemService(ActivityTaskManager.class); mTaskOrganizer = taskOrganizer; + mDesktopRepository = desktopRepository; mShellController = shellController; mDisplayController = displayController; mDisplayInsetsController = displayInsetsController; @@ -560,20 +562,17 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { decoration.closeMaximizeMenu(); } - private void onOpenInBrowser(int taskId, @NonNull Uri uri) { + private void onOpenInBrowser(int taskId, @NonNull Intent intent) { final DesktopModeWindowDecoration decoration = mWindowDecorByTaskId.get(taskId); if (decoration == null) { return; } - openInBrowser(uri, decoration.getUser()); + openInBrowser(intent, decoration.getUser()); decoration.closeHandleMenu(); decoration.closeMaximizeMenu(); } - private void openInBrowser(Uri uri, @NonNull UserHandle userHandle) { - final Intent intent = Intent.makeMainSelectorActivity(ACTION_MAIN, CATEGORY_APP_BROWSER) - .setData(uri) - .addFlags(FLAG_ACTIVITY_NEW_TASK); + private void openInBrowser(@NonNull Intent intent, @NonNull UserHandle userHandle) { mContext.startActivityAsUser(intent, userHandle); } @@ -1421,6 +1420,7 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { mContext.createContextAsUser(UserHandle.of(taskInfo.userId), 0 /* flags */), mDisplayController, mSplitScreenController, + mDesktopRepository, mTaskOrganizer, taskInfo, taskSurface, @@ -1472,8 +1472,8 @@ public class DesktopModeWindowDecorViewModel implements WindowDecorViewModel { onToSplitScreen(taskInfo.taskId); return Unit.INSTANCE; }); - windowDecoration.setOpenInBrowserClickListener((uri) -> { - onOpenInBrowser(taskInfo.taskId, uri); + windowDecoration.setOpenInBrowserClickListener((intent) -> { + onOpenInBrowser(taskInfo.taskId, intent); }); windowDecoration.setOnNewWindowClickListener(() -> { onNewWindow(taskInfo.taskId); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java index 8a53f5ba4a51..5daa3eee340b 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java @@ -44,12 +44,14 @@ import android.app.WindowConfiguration.WindowingMode; import android.app.assist.AssistContent; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Bitmap; +import android.graphics.Insets; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; @@ -62,10 +64,12 @@ import android.os.UserHandle; import android.util.Size; import android.util.Slog; import android.view.Choreographer; +import android.view.InsetsState; import android.view.MotionEvent; import android.view.SurfaceControl; import android.view.View; import android.view.ViewConfiguration; +import android.view.WindowInsets; import android.view.WindowManager; import android.widget.ImageButton; import android.window.TaskSnapshot; @@ -89,6 +93,7 @@ import com.android.wm.shell.common.MultiInstanceHelper; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SyncTransactionQueue; import com.android.wm.shell.desktopmode.CaptionState; +import com.android.wm.shell.desktopmode.DesktopModeTaskRepository; import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository; import com.android.wm.shell.shared.annotations.ShellBackgroundThread; import com.android.wm.shell.shared.desktopmode.DesktopModeStatus; @@ -166,7 +171,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin private CapturedLink mCapturedLink; private Uri mGenericLink; private Uri mWebUri; - private Consumer<Uri> mOpenInBrowserClickListener; + private Consumer<Intent> mOpenInBrowserClickListener; private ExclusionRegionListener mExclusionRegionListener; @@ -188,12 +193,14 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin private final Runnable mCapturedLinkExpiredRunnable = this::onCapturedLinkExpired; private final MultiInstanceHelper mMultiInstanceHelper; private final WindowDecorCaptionHandleRepository mWindowDecorCaptionHandleRepository; + private final DesktopModeTaskRepository mDesktopRepository; DesktopModeWindowDecoration( Context context, @NonNull Context userContext, DisplayController displayController, SplitScreenController splitScreenController, + DesktopModeTaskRepository desktopRepository, ShellTaskOrganizer taskOrganizer, ActivityManager.RunningTaskInfo taskInfo, SurfaceControl taskSurface, @@ -207,8 +214,8 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin AssistContentRequester assistContentRequester, MultiInstanceHelper multiInstanceHelper, WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository) { - this (context, userContext, displayController, splitScreenController, taskOrganizer, - taskInfo, taskSurface, handler, bgExecutor, choreographer, syncQueue, + this (context, userContext, displayController, splitScreenController, desktopRepository, + taskOrganizer, taskInfo, taskSurface, handler, bgExecutor, choreographer, syncQueue, appHeaderViewHolderFactory, rootTaskDisplayAreaOrganizer, genericLinksParser, assistContentRequester, SurfaceControl.Builder::new, SurfaceControl.Transaction::new, @@ -225,6 +232,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin @NonNull Context userContext, DisplayController displayController, SplitScreenController splitScreenController, + DesktopModeTaskRepository desktopRepository, ShellTaskOrganizer taskOrganizer, ActivityManager.RunningTaskInfo taskInfo, SurfaceControl taskSurface, @@ -264,6 +272,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin mMultiInstanceHelper = multiInstanceHelper; mWindowManagerWrapper = windowManagerWrapper; mWindowDecorCaptionHandleRepository = windowDecorCaptionHandleRepository; + mDesktopRepository = desktopRepository; } /** @@ -335,7 +344,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin mDragPositioningCallback = dragPositioningCallback; } - void setOpenInBrowserClickListener(Consumer<Uri> listener) { + void setOpenInBrowserClickListener(Consumer<Intent> listener) { mOpenInBrowserClickListener = listener; } @@ -439,8 +448,11 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin mHandleMenu.relayout(startT, mResult.mCaptionX); } + final boolean inFullImmersive = mDesktopRepository + .isTaskInFullImmersiveState(taskInfo.taskId); updateRelayoutParams(mRelayoutParams, mContext, taskInfo, applyStartTransactionOnDraw, - shouldSetTaskPositionAndCrop); + shouldSetTaskPositionAndCrop, mIsStatusBarVisible, mIsKeyguardVisibleAndOccluded, + inFullImmersive, mDisplayController.getInsetsState(taskInfo.displayId)); final WindowDecorLinearLayout oldRootView = mResult.mRootView; final SurfaceControl oldDecorationSurface = mDecorationContainerSurface; @@ -461,6 +473,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { notifyNoCaptionHandle(); } + mExclusionRegionListener.onExclusionRegionDismissed(mTaskInfo.taskId); disposeStatusBarInputLayer(); Trace.endSection(); // DesktopModeWindowDecoration#updateRelayoutParamsAndSurfaces return; @@ -479,11 +492,17 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) { notifyCaptionStateChanged(); } - mWindowDecorViewHolder.bindData(mTaskInfo, - position, - mResult.mCaptionWidth, - mResult.mCaptionHeight, - isCaptionVisible()); + + if (isAppHandle(mWindowDecorViewHolder)) { + mWindowDecorViewHolder.bindData(new AppHandleViewHolder.HandleData( + mTaskInfo, position, mResult.mCaptionWidth, mResult.mCaptionHeight, + isCaptionVisible() + )); + } else { + mWindowDecorViewHolder.bindData(new AppHeaderViewHolder.HeaderData( + mTaskInfo, TaskInfoKt.getRequestingImmersive(mTaskInfo), inFullImmersive + )); + } Trace.endSection(); if (!mTaskInfo.isFocused) { @@ -517,21 +536,28 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin } @Nullable - private Uri getBrowserLink() { + private Intent getBrowserLink() { // Do not show browser link in browser applications final ComponentName baseActivity = mTaskInfo.baseActivity; if (baseActivity != null && AppToWebUtils.isBrowserApp(mContext, baseActivity.getPackageName(), mUserContext.getUserId())) { return null; } + + final Uri browserLink; // If the captured link is available and has not expired, return the captured link. // Otherwise, return the generic link which is set to null if a generic link is unavailable. if (mCapturedLink != null && !mCapturedLink.mExpired) { - return mCapturedLink.mUri; + browserLink = mCapturedLink.mUri; } else if (mWebUri != null) { - return mWebUri; + browserLink = mWebUri; + } else { + browserLink = mGenericLink; } - return mGenericLink; + + if (browserLink == null) return null; + return AppToWebUtils.getBrowserIntent(browserLink, mContext.getPackageManager()); + } UserHandle getUser() { @@ -737,7 +763,11 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin Context context, ActivityManager.RunningTaskInfo taskInfo, boolean applyStartTransactionOnDraw, - boolean shouldSetTaskPositionAndCrop) { + boolean shouldSetTaskPositionAndCrop, + boolean isStatusBarVisible, + boolean isKeyguardVisibleAndOccluded, + boolean inFullImmersiveMode, + @NonNull InsetsState displayInsetsState) { final int captionLayoutId = getDesktopModeWindowDecorLayoutId(taskInfo.getWindowingMode()); final boolean isAppHeader = captionLayoutId == R.layout.desktop_mode_app_header; @@ -748,6 +778,28 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin relayoutParams.mCaptionHeightId = getCaptionHeightIdStatic(taskInfo.getWindowingMode()); relayoutParams.mCaptionWidthId = getCaptionWidthId(relayoutParams.mLayoutResId); + final boolean showCaption; + if (Flags.enableFullyImmersiveInDesktop()) { + if (inFullImmersiveMode) { + showCaption = isStatusBarVisible && !isKeyguardVisibleAndOccluded; + } else { + showCaption = taskInfo.isFreeform() + || (isStatusBarVisible && !isKeyguardVisibleAndOccluded); + } + } else { + // Caption should always be visible in freeform mode. When not in freeform, + // align with the status bar except when showing over keyguard (where it should not + // shown). + // TODO(b/356405803): Investigate how it's possible for the status bar visibility to + // be false while a freeform window is open if the status bar is always + // forcibly-shown. It may be that the InsetsState (from which |mIsStatusBarVisible| + // is set) still contains an invisible insets source in immersive cases even if the + // status bar is shown? + showCaption = taskInfo.isFreeform() + || (isStatusBarVisible && !isKeyguardVisibleAndOccluded); + } + relayoutParams.mIsCaptionVisible = showCaption; + if (isAppHeader) { if (TaskInfoKt.isTransparentCaptionBarAppearance(taskInfo)) { // If the app is requesting to customize the caption bar, allow input to fall @@ -766,6 +818,13 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin // including non-immersive apps that just don't handle caption insets properly. relayoutParams.mInsetSourceFlags |= FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR; } + if (Flags.enableFullyImmersiveInDesktop() && inFullImmersiveMode) { + final Insets systemBarInsets = displayInsetsState.calculateInsets( + taskInfo.getConfiguration().windowConfiguration.getBounds(), + WindowInsets.Type.systemBars() & ~WindowInsets.Type.captionBar(), + false /* ignoreVisibility */); + relayoutParams.mCaptionTopPadding = systemBarInsets.top; + } // Report occluding elements as bounding rects to the insets system so that apps can // draw in the empty space in the center: // First, the "app chip" section of the caption bar (+ some extra margins). @@ -1048,7 +1107,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin } /** - * Determine the highest y coordinate of a freeform task. Used for restricting drag inputs. + * Determine the highest y coordinate of a freeform task. Used for restricting drag inputs.fmdra */ private int determineMaxY(int requiredEmptySpace, Rect stableBounds) { return stableBounds.bottom - requiredEmptySpace; @@ -1171,8 +1230,8 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin /* onToSplitScreenClickListener= */ mOnToSplitscreenClickListener, /* onNewWindowClickListener= */ mOnNewWindowClickListener, /* onManageWindowsClickListener= */ mOnManageWindowsClickListener, - /* openInBrowserClickListener= */ (uri) -> { - mOpenInBrowserClickListener.accept(uri); + /* openInBrowserClickListener= */ (intent) -> { + mOpenInBrowserClickListener.accept(intent); onCapturedLinkExpired(); return Unit.INSTANCE; }, @@ -1531,6 +1590,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin @NonNull Context userContext, DisplayController displayController, SplitScreenController splitScreenController, + DesktopModeTaskRepository desktopRepository, ShellTaskOrganizer taskOrganizer, ActivityManager.RunningTaskInfo taskInfo, SurfaceControl taskSurface, @@ -1549,6 +1609,7 @@ public class DesktopModeWindowDecoration extends WindowDecoration<WindowDecorLin userContext, displayController, splitScreenController, + desktopRepository, taskOrganizer, taskInfo, taskSurface, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt index 9a5b4f54dd36..98fef4722d1c 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt @@ -20,13 +20,13 @@ import android.annotation.DimenRes import android.annotation.SuppressLint import android.app.ActivityManager.RunningTaskInfo import android.content.Context +import android.content.Intent import android.content.res.ColorStateList import android.content.res.Resources import android.graphics.Bitmap import android.graphics.Point import android.graphics.PointF import android.graphics.Rect -import android.net.Uri import android.view.LayoutInflater import android.view.MotionEvent import android.view.MotionEvent.ACTION_OUTSIDE @@ -70,7 +70,7 @@ class HandleMenu( private val shouldShowWindowingPill: Boolean, private val shouldShowNewWindowButton: Boolean, private val shouldShowManageWindowsButton: Boolean, - private val openInBrowserLink: Uri?, + private val openInBrowserIntent: Intent?, private val captionWidth: Int, private val captionHeight: Int, captionX: Int @@ -107,7 +107,7 @@ class HandleMenu( private val globalMenuPosition: Point = Point() private val shouldShowBrowserPill: Boolean - get() = openInBrowserLink != null + get() = openInBrowserIntent != null init { updateHandleMenuPillPositions(captionX) @@ -119,7 +119,7 @@ class HandleMenu( onToSplitScreenClickListener: () -> Unit, onNewWindowClickListener: () -> Unit, onManageWindowsClickListener: () -> Unit, - openInBrowserClickListener: (Uri) -> Unit, + openInBrowserClickListener: (Intent) -> Unit, onCloseMenuClickListener: () -> Unit, onOutsideTouchListener: () -> Unit, ) { @@ -152,7 +152,7 @@ class HandleMenu( onToSplitScreenClickListener: () -> Unit, onNewWindowClickListener: () -> Unit, onManageWindowsClickListener: () -> Unit, - openInBrowserClickListener: (Uri) -> Unit, + openInBrowserClickListener: (Intent) -> Unit, onCloseMenuClickListener: () -> Unit, onOutsideTouchListener: () -> Unit ) { @@ -172,7 +172,7 @@ class HandleMenu( this.onNewWindowClickListener = onNewWindowClickListener this.onManageWindowsClickListener = onManageWindowsClickListener this.onOpenInBrowserClickListener = { - openInBrowserClickListener.invoke(openInBrowserLink!!) + openInBrowserClickListener.invoke(openInBrowserIntent!!) } this.onCloseMenuClickListener = onCloseMenuClickListener this.onOutsideTouchListener = onOutsideTouchListener @@ -661,7 +661,7 @@ interface HandleMenuFactory { shouldShowWindowingPill: Boolean, shouldShowNewWindowButton: Boolean, shouldShowManageWindowsButton: Boolean, - openInBrowserLink: Uri?, + openInBrowserIntent: Intent?, captionWidth: Int, captionHeight: Int, captionX: Int @@ -680,7 +680,7 @@ object DefaultHandleMenuFactory : HandleMenuFactory { shouldShowWindowingPill: Boolean, shouldShowNewWindowButton: Boolean, shouldShowManageWindowsButton: Boolean, - openInBrowserLink: Uri?, + openInBrowserIntent: Intent?, captionWidth: Int, captionHeight: Int, captionX: Int @@ -695,7 +695,7 @@ object DefaultHandleMenuFactory : HandleMenuFactory { shouldShowWindowingPill, shouldShowNewWindowButton, shouldShowManageWindowsButton, - openInBrowserLink, + openInBrowserIntent, captionWidth, captionHeight, captionX diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java index c1a55b48a02a..000beba125b0 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java @@ -144,8 +144,8 @@ public abstract class WindowDecoration<T extends View & TaskFocusStateConsumer> TaskDragResizer mTaskDragResizer; boolean mIsCaptionVisible; - private boolean mIsStatusBarVisible; - private boolean mIsKeyguardVisibleAndOccluded; + boolean mIsStatusBarVisible; + boolean mIsKeyguardVisibleAndOccluded; /** The most recent set of insets applied to this window decoration. */ private WindowDecorationInsets mWindowDecorationInsets; @@ -241,7 +241,7 @@ public abstract class WindowDecoration<T extends View & TaskFocusStateConsumer> } rootView = null; // Clear it just in case we use it accidentally - updateCaptionVisibility(outResult.mRootView); + updateCaptionVisibility(outResult.mRootView, params); final Rect taskBounds = mTaskInfo.getConfiguration().windowConfiguration.getBounds(); outResult.mWidth = taskBounds.width(); @@ -527,17 +527,10 @@ public abstract class WindowDecoration<T extends View & TaskFocusStateConsumer> } /** - * Checks if task has entered/exited immersive mode and requires a change in caption visibility. + * Update caption visibility state and views. */ - private void updateCaptionVisibility(View rootView) { - // Caption should always be visible in freeform mode. When not in freeform, align with the - // status bar except when showing over keyguard (where it should not shown). - // TODO(b/356405803): Investigate how it's possible for the status bar visibility to be - // false while a freeform window is open if the status bar is always forcibly-shown. It - // may be that the InsetsState (from which |mIsStatusBarVisible| is set) still contains - // an invisible insets source in immersive cases even if the status bar is shown? - mIsCaptionVisible = mTaskInfo.isFreeform() - || (mIsStatusBarVisible && !mIsKeyguardVisibleAndOccluded); + private void updateCaptionVisibility(View rootView, @NonNull RelayoutParams params) { + mIsCaptionVisible = params.mIsCaptionVisible; setCaptionVisibility(rootView, mIsCaptionVisible); } @@ -737,6 +730,7 @@ public abstract class WindowDecoration<T extends View & TaskFocusStateConsumer> int mCornerRadius; int mCaptionTopPadding; + boolean mIsCaptionVisible; Configuration mWindowDecorConfig; @@ -755,6 +749,7 @@ public abstract class WindowDecoration<T extends View & TaskFocusStateConsumer> mCornerRadius = 0; mCaptionTopPadding = 0; + mIsCaptionVisible = false; mApplyStartTransactionOnDraw = false; mSetTaskPositionAndCrop = false; diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt index 8c102ebfb590..b5700ffb046b 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt @@ -42,6 +42,7 @@ import com.android.wm.shell.R import com.android.wm.shell.shared.animation.Interpolators import com.android.wm.shell.windowdecor.WindowManagerWrapper import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer +import com.android.wm.shell.windowdecor.viewholder.WindowDecorationViewHolder.Data /** * A desktop mode window decoration used when the window is in full "focus" (i.e. fullscreen/split). @@ -53,11 +54,20 @@ internal class AppHandleViewHolder( onCaptionButtonClickListener: OnClickListener, private val windowManagerWrapper: WindowManagerWrapper, private val handler: Handler -) : WindowDecorationViewHolder(rootView) { +) : WindowDecorationViewHolder<AppHandleViewHolder.HandleData>(rootView) { companion object { private const val CAPTION_HANDLE_ANIMATION_DURATION: Long = 100 } + + data class HandleData( + val taskInfo: RunningTaskInfo, + val position: Point, + val width: Int, + val height: Int, + val isCaptionVisible: Boolean + ) : Data() + private lateinit var taskInfo: RunningTaskInfo private val captionView: View = rootView.requireViewById(R.id.desktop_mode_caption) private val captionHandle: ImageButton = rootView.requireViewById(R.id.caption_handle) @@ -89,7 +99,11 @@ internal class AppHandleViewHolder( } } - override fun bindData( + override fun bindData(data: HandleData) { + bindData(data.taskInfo, data.position, data.width, data.height, data.isCaptionVisible) + } + + private fun bindData( taskInfo: RunningTaskInfo, position: Point, width: Int, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt index 306103c9b492..52bf40062cdb 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt @@ -16,12 +16,12 @@ package com.android.wm.shell.windowdecor.viewholder import android.annotation.ColorInt +import android.annotation.DrawableRes import android.app.ActivityManager.RunningTaskInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color -import android.graphics.Point import android.graphics.Rect import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable @@ -60,7 +60,6 @@ import com.android.wm.shell.windowdecor.common.OPACITY_65 import com.android.wm.shell.windowdecor.common.Theme import com.android.wm.shell.windowdecor.extension.isLightCaptionBarAppearance import com.android.wm.shell.windowdecor.extension.isTransparentCaptionBarAppearance -import com.android.wm.shell.windowdecor.extension.requestingImmersive /** * A desktop mode window decoration used when the window is floating (i.e. freeform). It hosts @@ -76,7 +75,13 @@ class AppHeaderViewHolder( appName: CharSequence, appIconBitmap: Bitmap, onMaximizeHoverAnimationFinishedListener: () -> Unit -) : WindowDecorationViewHolder(rootView) { +) : WindowDecorationViewHolder<AppHeaderViewHolder.HeaderData>(rootView) { + + data class HeaderData( + val taskInfo: RunningTaskInfo, + val isRequestingImmersive: Boolean, + val inFullImmersiveState: Boolean, + ) : Data() private val decorThemeUtil = DecorThemeUtil(context) private val lightColors = dynamicLightColorScheme(context) @@ -153,15 +158,17 @@ class AppHeaderViewHolder( onMaximizeHoverAnimationFinishedListener } - override fun bindData( + override fun bindData(data: HeaderData) { + bindData(data.taskInfo, data.isRequestingImmersive, data.inFullImmersiveState) + } + + private fun bindData( taskInfo: RunningTaskInfo, - position: Point, - width: Int, - height: Int, - isCaptionVisible: Boolean + isRequestingImmersive: Boolean, + inFullImmersiveState: Boolean, ) { if (DesktopModeFlags.ENABLE_THEMED_APP_HEADERS.isTrue()) { - bindDataWithThemedHeaders(taskInfo) + bindDataWithThemedHeaders(taskInfo, isRequestingImmersive, inFullImmersiveState) } else { bindDataLegacy(taskInfo) } @@ -200,7 +207,11 @@ class AppHeaderViewHolder( minimizeWindowButton.isGone = !enableMinimizeButton() } - private fun bindDataWithThemedHeaders(taskInfo: RunningTaskInfo) { + private fun bindDataWithThemedHeaders( + taskInfo: RunningTaskInfo, + requestingImmersive: Boolean, + inFullImmersiveState: Boolean + ) { val header = fillHeaderInfo(taskInfo) val headerStyle = getHeaderStyle(header) @@ -254,13 +265,7 @@ class AppHeaderViewHolder( drawableInsets = maximizeDrawableInsets ) ) - setIcon( - if (taskInfo.requestingImmersive && Flags.enableFullyImmersiveInDesktop()) { - R.drawable.decor_desktop_mode_immersive_button_dark - } else { - R.drawable.decor_desktop_mode_maximize_button_dark - } - ) + setIcon(getMaximizeButtonIcon(requestingImmersive, inFullImmersiveState)) } // Close button. closeWindowButton.apply { @@ -331,6 +336,32 @@ class AppHeaderViewHolder( } } + @DrawableRes + private fun getMaximizeButtonIcon( + requestingImmersive: Boolean, + inFullImmersiveState: Boolean + ): Int = when { + shouldShowEnterFullImmersiveIcon(requestingImmersive, inFullImmersiveState) -> { + R.drawable.decor_desktop_mode_immersive_button_dark + } + shouldShowExitFullImmersiveIcon(requestingImmersive, inFullImmersiveState) -> { + R.drawable.decor_desktop_mode_immersive_exit_button_dark + } + else -> R.drawable.decor_desktop_mode_maximize_button_dark + } + + private fun shouldShowEnterFullImmersiveIcon( + requestingImmersive: Boolean, + inFullImmersiveState: Boolean + ): Boolean = Flags.enableFullyImmersiveInDesktop() + && requestingImmersive && !inFullImmersiveState + + private fun shouldShowExitFullImmersiveIcon( + requestingImmersive: Boolean, + inFullImmersiveState: Boolean + ): Boolean = Flags.enableFullyImmersiveInDesktop() + && requestingImmersive && inFullImmersiveState + private fun getHeaderStyle(header: Header): HeaderStyle { return HeaderStyle( background = getHeaderBackground(header), diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt index 5ea55b367703..1fe743da966a 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt @@ -17,31 +17,28 @@ package com.android.wm.shell.windowdecor.viewholder import android.app.ActivityManager.RunningTaskInfo import android.content.Context -import android.graphics.Point import android.view.View +import com.android.wm.shell.windowdecor.viewholder.WindowDecorationViewHolder.Data /** * Encapsulates the root [View] of a window decoration and its children to facilitate looking up * children (via findViewById) and updating to the latest data from [RunningTaskInfo]. */ -abstract class WindowDecorationViewHolder(rootView: View) { +abstract class WindowDecorationViewHolder<T : Data>(rootView: View) { val context: Context = rootView.context /** * A signal to the view holder that new data is available and that the views should be updated to * reflect it. */ - abstract fun bindData( - taskInfo: RunningTaskInfo, - position: Point, - width: Int, - height: Int, - isCaptionVisible: Boolean - ) + abstract fun bindData(data: T) /** Callback when the handle menu is opened. */ abstract fun onHandleMenuOpened() /** Callback when the handle menu is closed. */ abstract fun onHandleMenuClosed() + + /** Data clas that contains the information needed to update the view holder. */ + abstract class Data } diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt index bc40d89009bc..794f9d819f4b 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt @@ -882,6 +882,51 @@ class DesktopModeTaskRepositoryTest : ShellTestCase() { assertThat(tasks).containsExactly(1, 3).inOrder() } + @Test + fun setTaskInFullImmersiveState_savedAsInImmersiveState() { + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse() + + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true) + + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue() + } + + @Test + fun removeTaskInFullImmersiveState_removedAsInImmersiveState() { + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true) + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue() + + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = false) + + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse() + } + + @Test + fun removeTaskInFullImmersiveState_otherWasImmersive_otherRemainsImmersive() { + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true) + + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 2, immersive = false) + + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue() + } + + @Test + fun setTaskInFullImmersiveState_sameDisplay_overridesExistingFullImmersiveTask() { + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true) + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 2, immersive = true) + + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse() + assertThat(repo.isTaskInFullImmersiveState(taskId = 2)).isTrue() + } + + @Test + fun setTaskInFullImmersiveState_differentDisplay_bothAreImmersive() { + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true) + repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID + 1, taskId = 2, immersive = true) + + assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue() + assertThat(repo.isTaskInFullImmersiveState(taskId = 2)).isTrue() + } class TestListener : DesktopModeTaskRepository.ActiveTasksListener { var activeChangesOnDefaultDisplay = 0 diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java index 5f7542332c80..ce482cdd9944 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java @@ -30,7 +30,6 @@ import static com.android.wm.shell.startingsurface.SplashscreenContentDrawer.MIN import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.times; @@ -56,12 +55,11 @@ import android.os.IBinder; import android.os.Looper; import android.os.UserHandle; import android.testing.TestableContext; -import android.view.IWindowSession; import android.view.InsetsState; import android.view.Surface; import android.view.WindowManager; -import android.view.WindowManagerGlobal; import android.view.WindowMetrics; +import android.window.SnapshotDrawerUtils; import android.window.StartingWindowInfo; import android.window.StartingWindowRemovalInfo; import android.window.TaskSnapshot; @@ -220,18 +218,10 @@ public class StartingSurfaceDrawerTests extends ShellTestCase { createWindowInfo(taskId, android.R.style.Theme, mBinder); TaskSnapshot snapshot = createTaskSnapshot(100, 100, new Point(100, 100), new Rect(0, 0, 0, 50), true /* hasImeSurface */); - final IWindowSession session = WindowManagerGlobal.getWindowSession(); - spyOn(session); - doReturn(WindowManagerGlobal.ADD_OKAY).when(session).addToDisplay( - any() /* window */, any() /* attrs */, - anyInt() /* viewVisibility */, anyInt() /* displayId */, - anyInt() /* requestedVisibleTypes */, any() /* outInputChannel */, - any() /* outInsetsState */, any() /* outActiveControls */, - any() /* outAttachedFrame */, any() /* outSizeCompatScale */); - TaskSnapshotWindow mockSnapshotWindow = TaskSnapshotWindow.create(windowInfo, - mBinder, - snapshot, mTestExecutor, () -> { - }); + final TaskSnapshotWindow mockSnapshotWindow = new TaskSnapshotWindow( + snapshot, SnapshotDrawerUtils.getOrCreateTaskDescription(windowInfo.taskInfo), + snapshot.getOrientation(), + () -> {}, mTestExecutor); spyOn(mockSnapshotWindow); try (AutoCloseable mockTaskSnapshotSession = new AutoCloseable() { MockitoSession mockSession = mockitoSession() diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java index d37b4cf4b4b3..d63158c29688 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java @@ -18,7 +18,7 @@ package com.android.wm.shell.transition; import static android.view.Display.DEFAULT_DISPLAY; import static android.view.WindowManager.TRANSIT_OPEN; -import static android.view.WindowManager.TRANSIT_TO_FRONT; +import static android.window.TransitionInfo.FLAG_IS_DISPLAY; import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP; import static com.android.dx.mockito.inline.extended.ExtendedMockito.verify; @@ -97,50 +97,38 @@ public class FocusTransitionObserverTest extends ShellTestCase { } @Test - public void testTransitionWithMovedToFrontFlagChangesDisplayFocus() throws RemoteException { + public void testOnlyDisplayChangeAffectsDisplayFocus() throws RemoteException { final IBinder binder = mock(IBinder.class); final SurfaceControl.Transaction tx = mock(SurfaceControl.Transaction.class); - // Open a task on the default display, which doesn't change display focus because the - // default display already has it. + // Open a task on the secondary display, but it doesn't change display focus because it only + // has a task change. TransitionInfo info = mock(TransitionInfo.class); final List<TransitionInfo.Change> changes = new ArrayList<>(); - setupChange(changes, 123 /* taskId */, TRANSIT_OPEN, DEFAULT_DISPLAY, + setupTaskChange(changes, 123 /* taskId */, TRANSIT_OPEN, SECONDARY_DISPLAY_ID, true /* focused */); when(info.getChanges()).thenReturn(changes); mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx); verify(mListener, never()).onFocusedDisplayChanged(SECONDARY_DISPLAY_ID); clearInvocations(mListener); - // Open a new task on the secondary display and verify display focus changes to the display. + // Moving the secondary display to front must change display focus to it. changes.clear(); - setupChange(changes, 456 /* taskId */, TRANSIT_OPEN, SECONDARY_DISPLAY_ID, - true /* focused */); + setupDisplayToTopChange(changes, SECONDARY_DISPLAY_ID); when(info.getChanges()).thenReturn(changes); mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx); - verify(mListener, times(1)).onFocusedDisplayChanged(SECONDARY_DISPLAY_ID); - clearInvocations(mListener); + verify(mListener, times(1)) + .onFocusedDisplayChanged(SECONDARY_DISPLAY_ID); - // Open the first task to front and verify display focus goes back to the default display. + // Moving the secondary display to front must change display focus back to it. changes.clear(); - setupChange(changes, 123 /* taskId */, TRANSIT_TO_FRONT, DEFAULT_DISPLAY, - true /* focused */); + setupDisplayToTopChange(changes, DEFAULT_DISPLAY); when(info.getChanges()).thenReturn(changes); mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx); verify(mListener, times(1)).onFocusedDisplayChanged(DEFAULT_DISPLAY); - clearInvocations(mListener); - - // Open another task on the default display and verify no display focus switch as it's - // already on the default display. - changes.clear(); - setupChange(changes, 789 /* taskId */, TRANSIT_OPEN, DEFAULT_DISPLAY, - true /* focused */); - when(info.getChanges()).thenReturn(changes); - mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx); - verify(mListener, never()).onFocusedDisplayChanged(DEFAULT_DISPLAY); } - private void setupChange(List<TransitionInfo.Change> changes, int taskId, + private void setupTaskChange(List<TransitionInfo.Change> changes, int taskId, @TransitionMode int mode, int displayId, boolean focused) { TransitionInfo.Change change = mock(TransitionInfo.Change.class); RunningTaskInfo taskInfo = mock(RunningTaskInfo.class); @@ -152,4 +140,12 @@ public class FocusTransitionObserverTest extends ShellTestCase { when(change.getMode()).thenReturn(mode); changes.add(change); } + + private void setupDisplayToTopChange(List<TransitionInfo.Change> changes, int displayId) { + TransitionInfo.Change change = mock(TransitionInfo.Change.class); + when(change.hasFlags(FLAG_MOVED_TO_TOP)).thenReturn(true); + when(change.hasFlags(FLAG_IS_DISPLAY)).thenReturn(true); + when(change.getEndDisplayId()).thenReturn(displayId); + changes.add(change); + } } diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt index d141c2d771ce..0f16b9d0fa7e 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt @@ -47,6 +47,8 @@ class CaptionWindowDecorationTests : ShellTestCase() { taskInfo, true, false, + true /* isStatusBarVisible */, + false /* isKeyguardVisibleAndOccluded */, InsetsState() ) @@ -66,6 +68,8 @@ class CaptionWindowDecorationTests : ShellTestCase() { taskInfo, true, false, + true /* isStatusBarVisible */, + false /* isKeyguardVisibleAndOccluded */, InsetsState() ) @@ -81,6 +85,8 @@ class CaptionWindowDecorationTests : ShellTestCase() { taskInfo, true, false, + true /* isStatusBarVisible */, + false /* isKeyguardVisibleAndOccluded */, InsetsState() ) Truth.assertThat(relayoutParams.mOccludingCaptionElements.size).isEqualTo(2) diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt index 9aa6a52fd851..5ae4ca839d61 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt @@ -27,6 +27,7 @@ import android.app.WindowConfiguration.WindowingMode import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.Intent.ACTION_MAIN import android.content.pm.ActivityInfo import android.graphics.Rect import android.hardware.display.DisplayManager @@ -86,6 +87,7 @@ import com.android.wm.shell.common.MultiInstanceHelper import com.android.wm.shell.common.ShellExecutor import com.android.wm.shell.common.SyncTransactionQueue import com.android.wm.shell.desktopmode.DesktopActivityOrientationChangeHandler +import com.android.wm.shell.desktopmode.DesktopModeTaskRepository import com.android.wm.shell.desktopmode.DesktopTasksController import com.android.wm.shell.desktopmode.DesktopTasksController.SnapPosition import com.android.wm.shell.desktopmode.DesktopTasksLimiter @@ -159,6 +161,7 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { @Mock private lateinit var mockTaskOrganizer: ShellTaskOrganizer @Mock private lateinit var mockDisplayController: DisplayController @Mock private lateinit var mockSplitScreenController: SplitScreenController + @Mock private lateinit var mockDesktopRepository: DesktopModeTaskRepository @Mock private lateinit var mockDisplayLayout: DisplayLayout @Mock private lateinit var displayInsetsController: DisplayInsetsController @Mock private lateinit var mockSyncQueue: SyncTransactionQueue @@ -230,6 +233,7 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { mockShellCommandHandler, mockWindowManager, mockTaskOrganizer, + mockDesktopRepository, mockDisplayController, mockShellController, displayInsetsController, @@ -930,13 +934,13 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { @Test fun testDecor_onClickToOpenBrowser_closeMenus() { val openInBrowserListenerCaptor = forClass(Consumer::class.java) - as ArgumentCaptor<Consumer<Uri>> + as ArgumentCaptor<Consumer<Intent>> val decor = createOpenTaskDecoration( windowingMode = WINDOWING_MODE_FULLSCREEN, onOpenInBrowserClickListener = openInBrowserListenerCaptor ) - openInBrowserListenerCaptor.value.accept(Uri.EMPTY) + openInBrowserListenerCaptor.value.accept(Intent()) verify(decor).closeHandleMenu() verify(decor).closeMaximizeMenu() @@ -946,20 +950,19 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { fun testDecor_onClickToOpenBrowser_opensBrowser() { doNothing().whenever(spyContext).startActivity(any()) val uri = Uri.parse("https://www.google.com") + val intent = Intent(ACTION_MAIN, uri) val openInBrowserListenerCaptor = forClass(Consumer::class.java) - as ArgumentCaptor<Consumer<Uri>> + as ArgumentCaptor<Consumer<Intent>> createOpenTaskDecoration( windowingMode = WINDOWING_MODE_FULLSCREEN, onOpenInBrowserClickListener = openInBrowserListenerCaptor ) - openInBrowserListenerCaptor.value.accept(uri) + openInBrowserListenerCaptor.value.accept(intent) verify(spyContext).startActivityAsUser(argThat { intent -> - intent.data == uri - && ((intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK) != 0) - && intent.categories.contains(Intent.CATEGORY_LAUNCHER) - && intent.action == Intent.ACTION_MAIN + uri.equals(intent.data) + && intent.action == ACTION_MAIN }, eq(mockUserHandle)) } @@ -1233,8 +1236,8 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { forClass(Function0::class.java) as ArgumentCaptor<Function0<Unit>>, onToSplitScreenClickListenerCaptor: ArgumentCaptor<Function0<Unit>> = forClass(Function0::class.java) as ArgumentCaptor<Function0<Unit>>, - onOpenInBrowserClickListener: ArgumentCaptor<Consumer<Uri>> = - forClass(Consumer::class.java) as ArgumentCaptor<Consumer<Uri>>, + onOpenInBrowserClickListener: ArgumentCaptor<Consumer<Intent>> = + forClass(Consumer::class.java) as ArgumentCaptor<Consumer<Intent>>, onCaptionButtonClickListener: ArgumentCaptor<View.OnClickListener> = forClass(View.OnClickListener::class.java) as ArgumentCaptor<View.OnClickListener>, onCaptionButtonTouchListener: ArgumentCaptor<View.OnTouchListener> = @@ -1296,8 +1299,8 @@ class DesktopModeWindowDecorViewModelTests : ShellTestCase() { val decoration = mock(DesktopModeWindowDecoration::class.java) whenever( mockDesktopModeWindowDecorFactory.create( - any(), any(), any(), any(), any(), eq(task), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any()) + any(), any(), any(), any(), any(), any(), eq(task), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any()) ).thenReturn(decoration) decoration.mTaskInfo = task whenever(decoration.isFocused).thenReturn(task.isFocused) diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java index f007115c6dab..3e7f3bdd72a2 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java @@ -23,6 +23,7 @@ import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED; import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT; import static android.view.InsetsSource.FLAG_FORCE_CONSUMING; import static android.view.InsetsSource.FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR; +import static android.view.WindowInsets.Type.captionBar; import static android.view.WindowInsets.Type.statusBars; import static android.view.WindowInsetsController.APPEARANCE_TRANSPARENT_CAPTION_BAR_BACKGROUND; @@ -37,6 +38,7 @@ import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; @@ -53,9 +55,11 @@ import android.app.ActivityManager; import android.app.assist.AssistContent; import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.PointF; @@ -102,6 +106,7 @@ import com.android.wm.shell.common.MultiInstanceHelper; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SyncTransactionQueue; import com.android.wm.shell.desktopmode.CaptionState; +import com.android.wm.shell.desktopmode.DesktopModeTaskRepository; import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository; import com.android.wm.shell.shared.desktopmode.DesktopModeStatus; import com.android.wm.shell.splitscreen.SplitScreenController; @@ -124,6 +129,7 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.quality.Strictness; +import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; @@ -157,6 +163,8 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { @Mock private ShellTaskOrganizer mMockShellTaskOrganizer; @Mock + private DesktopModeTaskRepository mMockDesktopRepository; + @Mock private Choreographer mMockChoreographer; @Mock private SyncTransactionQueue mMockSyncQueue; @@ -187,7 +195,7 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { @Mock private Handler mMockHandler; @Mock - private Consumer<Uri> mMockOpenInBrowserClickListener; + private Consumer<Intent> mMockOpenInBrowserClickListener; @Mock private AppToWebGenericLinksParser mMockGenericLinksParser; @Mock @@ -242,9 +250,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { when(mMockMultiInstanceHelper.supportsMultiInstanceSplit(any())) .thenReturn(false); when(mMockPackageManager.getApplicationLabel(any())).thenReturn("applicationLabel"); - final ActivityInfo activityInfo = new ActivityInfo(); - activityInfo.applicationInfo = new ApplicationInfo(); + final ActivityInfo activityInfo = createActivityInfo(); when(mMockPackageManager.getActivityInfo(any(), anyInt())).thenReturn(activityInfo); + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = activityInfo; + when(mMockPackageManager.resolveActivity(any(), anyInt())).thenReturn(resolveInfo); final Display defaultDisplay = mock(Display.class); doReturn(defaultDisplay).when(mMockDisplayController).getDisplay(Display.DEFAULT_DISPLAY); doReturn(mInsetsState).when(mMockDisplayController).getInsetsState(anyInt()); @@ -284,7 +294,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { DesktopModeWindowDecoration.updateRelayoutParams( relayoutParams, mContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.mShadowRadiusId).isNotEqualTo(Resources.ID_NULL); } @@ -300,7 +314,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.mCornerRadius).isGreaterThan(0); } @@ -321,7 +339,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.mWindowDecorConfig.densityDpi).isEqualTo(customTaskDensity); } @@ -343,7 +365,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.mWindowDecorConfig.densityDpi).isEqualTo(systemDensity); } @@ -361,7 +387,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.hasInputFeatureSpy()).isTrue(); } @@ -378,7 +408,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.hasInputFeatureSpy()).isFalse(); } @@ -394,7 +428,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(relayoutParams.hasInputFeatureSpy()).isFalse(); } @@ -410,7 +448,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(hasNoInputChannelFeature(relayoutParams)).isFalse(); } @@ -427,7 +469,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(hasNoInputChannelFeature(relayoutParams)).isTrue(); } @@ -444,7 +490,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat(hasNoInputChannelFeature(relayoutParams)).isTrue(); } @@ -462,7 +512,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat((relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING) != 0).isTrue(); } @@ -481,7 +535,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat((relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING) == 0).isTrue(); } @@ -498,7 +556,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat( (relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR) != 0) @@ -517,7 +579,11 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { mTestableContext, taskInfo, /* applyStartTransactionOnDraw= */ true, - /* shouldSetTaskPositionAndCrop */ false); + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); assertThat( (relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR) == 0) @@ -525,6 +591,171 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { } @Test + @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP) + public void updateRelayoutParams_header_addsPaddingInFullImmersive() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM); + taskInfo.configuration.windowConfiguration.setBounds(new Rect(0, 0, 1000, 2000)); + final InsetsState insetsState = createInsetsState(List.of( + createInsetsSource( + 0 /* id */, statusBars(), true /* visible */, new Rect(0, 0, 1000, 50)), + createInsetsSource( + 1 /* id */, captionBar(), true /* visible */, new Rect(0, 0, 1000, 100)))); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ true, + insetsState); + + // Takes status bar inset as padding, ignores caption bar inset. + assertThat(relayoutParams.mCaptionTopPadding).isEqualTo(50); + } + + @Test + @DisableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP) + public void updateRelayoutParams_header_statusBarInvisible_captionVisible() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ false, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); + + // Header is always shown because it's assumed the status bar is always visible. + assertThat(relayoutParams.mIsCaptionVisible).isTrue(); + } + + @Test + public void updateRelayoutParams_handle_statusBarVisibleAndNotOverKeyguard_captionVisible() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isTrue(); + } + + @Test + public void updateRelayoutParams_handle_statusBarInvisible_captionNotVisible() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ false, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ false, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isFalse(); + } + + @Test + public void updateRelayoutParams_handle_overKeyguard_captionNotVisible() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ true, + /* inFullImmersiveMode */ false, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isFalse(); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP) + public void updateRelayoutParams_header_fullyImmersive_captionVisFollowsStatusBar() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ true, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isTrue(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ false, + /* isKeyguardVisibleAndOccluded */ false, + /* inFullImmersiveMode */ true, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isFalse(); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP) + public void updateRelayoutParams_header_fullyImmersive_overKeyguard_captionNotVisible() { + final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); + taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM); + final RelayoutParams relayoutParams = new RelayoutParams(); + + DesktopModeWindowDecoration.updateRelayoutParams( + relayoutParams, + mTestableContext, + taskInfo, + /* applyStartTransactionOnDraw= */ true, + /* shouldSetTaskPositionAndCrop */ false, + /* isStatusBarVisible */ true, + /* isKeyguardVisibleAndOccluded */ true, + /* inFullImmersiveMode */ true, + new InsetsState()); + + assertThat(relayoutParams.mIsCaptionVisible).isFalse(); + } + + @Test public void relayout_fullscreenTask_appliesTransactionImmediately() { final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true); final DesktopModeWindowDecoration spyWindowDecor = spy(createWindowDecoration(taskInfo)); @@ -771,7 +1002,6 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { // Verify handle menu's browser link is set to captured link since menu was opened before // captured link expired - createHandleMenu(decor); verifyHandleMenuCreated(TEST_URI1); } @@ -782,7 +1012,7 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { final DesktopModeWindowDecoration decor = createWindowDecoration( taskInfo, TEST_URI1 /* captured link */, null /* web uri */, null /* generic link */); - final ArgumentCaptor<Function1<Uri, Unit>> openInBrowserCaptor = + final ArgumentCaptor<Function1<Intent, Unit>> openInBrowserCaptor = ArgumentCaptor.forClass(Function1.class); // Simulate menu opening and clicking open in browser button @@ -797,7 +1027,7 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { any(), any() ); - openInBrowserCaptor.getValue().invoke(TEST_URI1); + openInBrowserCaptor.getValue().invoke(new Intent(Intent.ACTION_MAIN, TEST_URI1)); // Verify handle menu's browser link not set to captured link since link not valid after // open in browser clicked @@ -812,7 +1042,7 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { final DesktopModeWindowDecoration decor = createWindowDecoration( taskInfo, TEST_URI1 /* captured link */, null /* web uri */, null /* generic link */); - final ArgumentCaptor<Function1<Uri, Unit>> openInBrowserCaptor = + final ArgumentCaptor<Function1<Intent, Unit>> openInBrowserCaptor = ArgumentCaptor.forClass(Function1.class); createHandleMenu(decor); verify(mMockHandleMenu).show( @@ -826,9 +1056,10 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { any() ); - openInBrowserCaptor.getValue().invoke(TEST_URI1); + openInBrowserCaptor.getValue().invoke(new Intent(Intent.ACTION_MAIN, TEST_URI1)); - verify(mMockOpenInBrowserClickListener).accept(TEST_URI1); + verify(mMockOpenInBrowserClickListener).accept( + argThat(intent -> intent.getData() == TEST_URI1)); } @Test @@ -1021,8 +1252,9 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { private void verifyHandleMenuCreated(@Nullable Uri uri) { verify(mMockHandleMenuFactory).create(any(), any(), anyInt(), any(), any(), - any(), anyBoolean(), anyBoolean(), anyBoolean(), eq(uri), anyInt(), - anyInt(), anyInt()); + any(), anyBoolean(), anyBoolean(), anyBoolean(), + argThat(intent -> (uri == null && intent == null) || intent.getData().equals(uri)), + anyInt(), anyInt(), anyInt()); } private void createMaximizeMenu(DesktopModeWindowDecoration decoration, MaximizeMenu menu) { @@ -1086,9 +1318,9 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { boolean relayout) { final DesktopModeWindowDecoration windowDecor = new DesktopModeWindowDecoration(mContext, mContext, mMockDisplayController, mMockSplitScreenController, - mMockShellTaskOrganizer, taskInfo, mMockSurfaceControl, mMockHandler, mBgExecutor, - mMockChoreographer, mMockSyncQueue, mMockAppHeaderViewHolderFactory, - mMockRootTaskDisplayAreaOrganizer, + mMockDesktopRepository, mMockShellTaskOrganizer, taskInfo, mMockSurfaceControl, + mMockHandler, mBgExecutor, mMockChoreographer, mMockSyncQueue, + mMockAppHeaderViewHolderFactory, mMockRootTaskDisplayAreaOrganizer, mMockGenericLinksParser, mMockAssistContentRequester, SurfaceControl.Builder::new, mMockTransactionSupplier, WindowContainerTransaction::new, SurfaceControl::new, new WindowManagerWrapper(mMockWindowManager), mMockSurfaceControlViewHostFactory, @@ -1128,19 +1360,39 @@ public class DesktopModeWindowDecorationTests extends ShellTestCase { decor.onAssistContentReceived(mAssistContent); } + private static ActivityInfo createActivityInfo() { + final ApplicationInfo applicationInfo = new ApplicationInfo(); + applicationInfo.packageName = "DesktopModeWindowDecorationTestPackage"; + final ActivityInfo activityInfo = new ActivityInfo(); + activityInfo.applicationInfo = applicationInfo; + activityInfo.name = "DesktopModeWindowDecorationTest"; + return activityInfo; + } + private static boolean hasNoInputChannelFeature(RelayoutParams params) { return (params.mInputFeatures & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) != 0; } - private InsetsState createInsetsState(@WindowInsets.Type.InsetsType int type, boolean visible) { - final InsetsState state = new InsetsState(); - final InsetsSource source = new InsetsSource(/* id= */0, type); + private InsetsSource createInsetsSource(int id, @WindowInsets.Type.InsetsType int type, + boolean visible, @NonNull Rect frame) { + final InsetsSource source = new InsetsSource(id, type); source.setVisible(visible); - state.addSource(source); + source.setFrame(frame); + return source; + } + + private InsetsState createInsetsState(@NonNull List<InsetsSource> sources) { + final InsetsState state = new InsetsState(); + sources.forEach(state::addSource); return state; } + private InsetsState createInsetsState(@WindowInsets.Type.InsetsType int type, boolean visible) { + final InsetsSource source = createInsetsSource(0 /* id */, type, visible, new Rect()); + return createInsetsState(List.of(source)); + } + private static class TestTouchEventListener extends GestureDetector.SimpleOnGestureListener implements View.OnClickListener, View.OnTouchListener, View.OnLongClickListener, View.OnGenericMotionListener, DragDetector.MotionEventHandler { diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java index 2e117ac9f865..94cabc492277 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java @@ -47,6 +47,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.same; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -246,6 +247,7 @@ public class WindowDecorationTests extends ShellTestCase { // Density is 2. Shadow radius is 10px. Caption height is 64px. taskInfo.configuration.densityDpi = DisplayMetrics.DENSITY_DEFAULT * 2; final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); + mRelayoutParams.mIsCaptionVisible = true; windowDecor.relayout(taskInfo); @@ -319,6 +321,7 @@ public class WindowDecorationTests extends ShellTestCase { taskInfo.configuration.densityDpi = DisplayMetrics.DENSITY_DEFAULT * 2; final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); + mRelayoutParams.mIsCaptionVisible = true; windowDecor.relayout(taskInfo); @@ -571,11 +574,7 @@ public class WindowDecorationTests extends ShellTestCase { .build(); final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); - assertTrue(mInsetsState.getOrCreateSource(STATUS_BAR_INSET_SOURCE_ID, statusBars()) - .isVisible()); - assertTrue(mInsetsState.sourceSize() == 1); - assertTrue(mInsetsState.sourceAt(0).getType() == statusBars()); - + mRelayoutParams.mIsCaptionVisible = true; windowDecor.relayout(taskInfo); verify(mMockWindowContainerTransaction).addInsetsSource(eq(taskInfo.token), any(), @@ -623,33 +622,6 @@ public class WindowDecorationTests extends ShellTestCase { } @Test - public void testRelayout_captionHidden_insetsRemoved() { - final Display defaultDisplay = mock(Display.class); - doReturn(defaultDisplay).when(mMockDisplayController) - .getDisplay(Display.DEFAULT_DISPLAY); - - final ActivityManager.RunningTaskInfo taskInfo = new TestRunningTaskInfoBuilder() - .setDisplayId(Display.DEFAULT_DISPLAY) - .setVisible(true) - .setBounds(new Rect(0, 0, 1000, 1000)) - .build(); - taskInfo.isFocused = true; - // Caption visible at first. - when(mMockDisplayController.getInsetsState(taskInfo.displayId)) - .thenReturn(createInsetsState(statusBars(), true /* visible */)); - final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); - windowDecor.relayout(taskInfo); - - // Hide caption so insets are removed. - windowDecor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */)); - - verify(mMockWindowContainerTransaction).removeInsetsSource(eq(taskInfo.token), any(), - eq(0) /* index */, eq(captionBar())); - verify(mMockWindowContainerTransaction).removeInsetsSource(eq(taskInfo.token), any(), - eq(0) /* index */, eq(mandatorySystemGestures())); - } - - @Test public void testRelayout_captionHidden_neverWasVisible_insetsNotRemoved() { final Display defaultDisplay = mock(Display.class); doReturn(defaultDisplay).when(mMockDisplayController) @@ -661,9 +633,8 @@ public class WindowDecorationTests extends ShellTestCase { .setBounds(new Rect(0, 0, 1000, 1000)) .build(); // Hidden from the beginning, so no insets were ever added. - when(mMockDisplayController.getInsetsState(taskInfo.displayId)) - .thenReturn(createInsetsState(statusBars(), false /* visible */)); final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); + mRelayoutParams.mIsCaptionVisible = false; windowDecor.relayout(taskInfo); // Never added. @@ -692,7 +663,7 @@ public class WindowDecorationTests extends ShellTestCase { final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); // Relayout will add insets. - mInsetsState.getOrCreateSource(STATUS_BAR_INSET_SOURCE_ID, captionBar()).setVisible(true); + mRelayoutParams.mIsCaptionVisible = true; windowDecor.relayout(taskInfo); verify(mMockWindowContainerTransaction).addInsetsSource(eq(taskInfo.token), any(), eq(0) /* index */, eq(captionBar()), any(), any(), anyInt()); @@ -740,6 +711,7 @@ public class WindowDecorationTests extends ShellTestCase { final TestRunningTaskInfoBuilder builder = new TestRunningTaskInfoBuilder() .setDisplayId(Display.DEFAULT_DISPLAY) .setVisible(true); + mRelayoutParams.mIsCaptionVisible = true; // Relayout twice with different bounds. final ActivityManager.RunningTaskInfo firstTaskInfo = @@ -767,6 +739,7 @@ public class WindowDecorationTests extends ShellTestCase { final TestRunningTaskInfoBuilder builder = new TestRunningTaskInfoBuilder() .setDisplayId(Display.DEFAULT_DISPLAY) .setVisible(true); + mRelayoutParams.mIsCaptionVisible = true; // Relayout twice with the same bounds. final ActivityManager.RunningTaskInfo firstTaskInfo = @@ -797,6 +770,7 @@ public class WindowDecorationTests extends ShellTestCase { final ActivityManager.RunningTaskInfo taskInfo = builder.setToken(token).setBounds(new Rect(0, 0, 1000, 1000)).build(); final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo); + mRelayoutParams.mIsCaptionVisible = true; mRelayoutParams.mInsetSourceFlags = FLAG_FORCE_CONSUMING | FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR; windowDecor.relayout(taskInfo); @@ -901,76 +875,61 @@ public class WindowDecorationTests extends ShellTestCase { } @Test - public void onStatusBarVisibilityChange_fullscreen_shownToHidden_hidesCaption() { + public void onStatusBarVisibilityChange() { final ActivityManager.RunningTaskInfo task = createTaskInfo(); task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN); when(mMockDisplayController.getInsetsState(task.displayId)) .thenReturn(createInsetsState(statusBars(), true /* visible */)); - final TestWindowDecoration decor = createWindowDecoration(task); + final TestWindowDecoration decor = spy(createWindowDecoration(task)); decor.relayout(task); - assertTrue(decor.mIsCaptionVisible); + assertTrue(decor.mIsStatusBarVisible); decor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */)); - assertFalse(decor.mIsCaptionVisible); + verify(decor, times(2)).relayout(task); } @Test - public void onStatusBarVisibilityChange_fullscreen_hiddenToShown_showsCaption() { + public void onStatusBarVisibilityChange_noChange_doesNotRelayout() { final ActivityManager.RunningTaskInfo task = createTaskInfo(); task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN); when(mMockDisplayController.getInsetsState(task.displayId)) - .thenReturn(createInsetsState(statusBars(), false /* visible */)); - final TestWindowDecoration decor = createWindowDecoration(task); - decor.relayout(task); - assertFalse(decor.mIsCaptionVisible); - - decor.onInsetsStateChanged(createInsetsState(statusBars(), true /* visible */)); - - assertTrue(decor.mIsCaptionVisible); - } - - @Test - public void onStatusBarVisibilityChange_freeform_shownToHidden_keepsCaption() { - final ActivityManager.RunningTaskInfo task = createTaskInfo(); - task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM); - when(mMockDisplayController.getInsetsState(task.displayId)) .thenReturn(createInsetsState(statusBars(), true /* visible */)); - final TestWindowDecoration decor = createWindowDecoration(task); + final TestWindowDecoration decor = spy(createWindowDecoration(task)); decor.relayout(task); - assertTrue(decor.mIsCaptionVisible); - decor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */)); + decor.onInsetsStateChanged(createInsetsState(statusBars(), true /* visible */)); - assertTrue(decor.mIsCaptionVisible); + verify(decor, times(1)).relayout(task); } @Test - public void onKeyguardStateChange_hiddenToShownAndOccluding_hidesCaption() { + public void onKeyguardStateChange() { final ActivityManager.RunningTaskInfo task = createTaskInfo(); when(mMockDisplayController.getInsetsState(task.displayId)) .thenReturn(createInsetsState(statusBars(), true /* visible */)); - final TestWindowDecoration decor = createWindowDecoration(task); + final TestWindowDecoration decor = spy(createWindowDecoration(task)); decor.relayout(task); - assertTrue(decor.mIsCaptionVisible); + assertFalse(decor.mIsKeyguardVisibleAndOccluded); decor.onKeyguardStateChanged(true /* visible */, true /* occluding */); - assertFalse(decor.mIsCaptionVisible); + assertTrue(decor.mIsKeyguardVisibleAndOccluded); + verify(decor, times(2)).relayout(task); } @Test - public void onKeyguardStateChange_showingAndOccludingToHidden_showsCaption() { + public void onKeyguardStateChange_noChange_doesNotRelayout() { final ActivityManager.RunningTaskInfo task = createTaskInfo(); when(mMockDisplayController.getInsetsState(task.displayId)) .thenReturn(createInsetsState(statusBars(), true /* visible */)); - final TestWindowDecoration decor = createWindowDecoration(task); - decor.onKeyguardStateChanged(true /* visible */, true /* occluding */); - assertFalse(decor.mIsCaptionVisible); + final TestWindowDecoration decor = spy(createWindowDecoration(task)); + decor.relayout(task); + assertFalse(decor.mIsKeyguardVisibleAndOccluded); - decor.onKeyguardStateChanged(false /* visible */, false /* occluding */); + decor.onKeyguardStateChanged(false /* visible */, true /* occluding */); - assertTrue(decor.mIsCaptionVisible); + verify(decor, times(1)).relayout(task); } private ActivityManager.RunningTaskInfo createTaskInfo() { diff --git a/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java b/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java index 34de5c4a5d75..e6726dcbb17a 100644 --- a/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java +++ b/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java @@ -68,6 +68,7 @@ public class SelectorWithWidgetPreference extends CheckBoxPreference { private View mExtraWidgetContainer; private ImageView mExtraWidget; + @Nullable private String mExtraWidgetContentDescription; private boolean mIsCheckBox = false; // whether to display this button as a checkbox private View.OnClickListener mExtraWidgetOnClickListener; @@ -173,6 +174,12 @@ public class SelectorWithWidgetPreference extends CheckBoxPreference { setExtraWidgetOnClickListener(mExtraWidgetOnClickListener); + if (mExtraWidget != null) { + mExtraWidget.setContentDescription(mExtraWidgetContentDescription != null + ? mExtraWidgetContentDescription + : getContext().getString(R.string.settings_label)); + } + if (Flags.allowSetTitleMaxLines()) { TextView title = (TextView) holder.findViewById(android.R.id.title); title.setMaxLines(mTitleMaxLines); @@ -210,6 +217,17 @@ public class SelectorWithWidgetPreference extends CheckBoxPreference { } /** + * Sets the content description of the extra widget. If {@code null}, a default content + * description will be used ("Settings"). + */ + public void setExtraWidgetContentDescription(@Nullable String contentDescription) { + if (!TextUtils.equals(mExtraWidgetContentDescription, contentDescription)) { + mExtraWidgetContentDescription = contentDescription; + notifyChanged(); + } + } + + /** * Returns whether this preference is a checkbox. */ public boolean isCheckBox() { diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java index 243ce85bd579..2b8b3b74dab9 100644 --- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java +++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java @@ -19,8 +19,6 @@ package com.android.settingslib.widget; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import android.app.Application; import android.platform.test.annotations.DisableFlags; @@ -68,7 +66,7 @@ public class SelectorWithWidgetPreferenceTest { mPreference = new SelectorWithWidgetPreference(mContext); View view = LayoutInflater.from(mContext) - .inflate(R.layout.preference_selector_with_widget, null /* root */); + .inflate(mPreference.getLayoutResource(), null /* root */); PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests(view); mPreference.onBindViewHolder(preferenceViewHolder); @@ -104,28 +102,28 @@ public class SelectorWithWidgetPreferenceTest { @Test public void onBindViewHolder_withSummary_containerShouldBeVisible() { mPreference.setSummary("some summary"); - View summaryContainer = new View(mContext); - View view = mock(View.class); - when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer); + View view = LayoutInflater.from(mContext) + .inflate(mPreference.getLayoutResource(), null /* root */); PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests(view); mPreference.onBindViewHolder(preferenceViewHolder); + View summaryContainer = view.findViewById(R.id.summary_container); assertEquals(View.VISIBLE, summaryContainer.getVisibility()); } @Test public void onBindViewHolder_emptySummary_containerShouldBeGone() { mPreference.setSummary(""); - View summaryContainer = new View(mContext); - View view = mock(View.class); - when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer); + View view = LayoutInflater.from(mContext) + .inflate(mPreference.getLayoutResource(), null /* root */); PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests(view); mPreference.onBindViewHolder(preferenceViewHolder); + View summaryContainer = view.findViewById(R.id.summary_container); assertEquals(View.GONE, summaryContainer.getVisibility()); } @@ -184,25 +182,49 @@ public class SelectorWithWidgetPreferenceTest { } @Test + public void onBindViewHolder_appliesWidgetContentDescription() { + mPreference = new SelectorWithWidgetPreference(mContext); + View view = LayoutInflater.from(mContext) + .inflate(mPreference.getLayoutResource(), /* root= */ null); + PreferenceViewHolder preferenceViewHolder = + PreferenceViewHolder.createInstanceForTests(view); + + mPreference.setExtraWidgetContentDescription("this is clearer"); + mPreference.onBindViewHolder(preferenceViewHolder); + + View widget = preferenceViewHolder.findViewById(R.id.selector_extra_widget); + assertThat(widget.getContentDescription().toString()).isEqualTo("this is clearer"); + + mPreference.setExtraWidgetContentDescription(null); + mPreference.onBindViewHolder(preferenceViewHolder); + + assertThat(widget.getContentDescription().toString()).isEqualTo("Settings"); + } + + @Test public void nullSummary_containerShouldBeGone() { mPreference.setSummary(null); - View summaryContainer = new View(mContext); - View view = mock(View.class); - when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer); + View view = LayoutInflater.from(mContext) + .inflate(mPreference.getLayoutResource(), null /* root */); PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests(view); + mPreference.onBindViewHolder(preferenceViewHolder); + + View summaryContainer = view.findViewById(R.id.summary_container); assertEquals(View.GONE, summaryContainer.getVisibility()); } @Test public void setAppendixVisibility_setGone_shouldBeGone() { mPreference.setAppendixVisibility(View.GONE); - View view = LayoutInflater.from(mContext) - .inflate(R.layout.preference_selector_with_widget, null /* root */); - PreferenceViewHolder holder = PreferenceViewHolder.createInstanceForTests(view); + .inflate(mPreference.getLayoutResource(), null /* root */); + PreferenceViewHolder holder = + PreferenceViewHolder.createInstanceForTests(view); + mPreference.onBindViewHolder(holder); + assertThat(holder.findViewById(R.id.appendix).getVisibility()).isEqualTo(View.GONE); } diff --git a/packages/SystemUI/aconfig/systemui.aconfig b/packages/SystemUI/aconfig/systemui.aconfig index b57629f8d9c5..d98b2da5a811 100644 --- a/packages/SystemUI/aconfig/systemui.aconfig +++ b/packages/SystemUI/aconfig/systemui.aconfig @@ -630,6 +630,13 @@ flag { } flag { + name: "screenshot_multidisplay_focus_change" + namespace: "systemui" + description: "Only capture a single display when screenshotting" + bug: "362720389" +} + +flag { name: "run_fingerprint_detect_on_dismissible_keyguard" namespace: "systemui" description: "Run fingerprint detect instead of authenticate if the keyguard is dismissible." diff --git a/packages/SystemUI/frp/Android.bp b/packages/SystemUI/frp/Android.bp new file mode 100644 index 000000000000..c3381db7a0e3 --- /dev/null +++ b/packages/SystemUI/frp/Android.bp @@ -0,0 +1,49 @@ +// +// Copyright (C) 2024 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package { + default_team: "trendy_team_system_ui_please_use_a_more_specific_subteam_if_possible_", + default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"], +} + +java_library { + name: "kt-frp", + host_supported: true, + kotlincflags: ["-opt-in=com.android.systemui.experimental.frp.ExperimentalFrpApi"], + srcs: ["src/**/*.kt"], + static_libs: [ + "kotlin-stdlib", + "kotlinx_coroutines", + ], +} + +java_test { + name: "kt-frp-test", + optimize: { + enabled: false, + }, + srcs: [ + "test/**/*.kt", + ], + static_libs: [ + "kt-frp", + "junit", + "kotlin-stdlib", + "kotlin-test", + "kotlinx_coroutines", + "kotlinx_coroutines_test", + ], +} diff --git a/packages/SystemUI/frp/OWNERS b/packages/SystemUI/frp/OWNERS new file mode 100644 index 000000000000..8876ad6f6224 --- /dev/null +++ b/packages/SystemUI/frp/OWNERS @@ -0,0 +1,3 @@ +steell@google.com +nijamkin@google.com +evanlaird@google.com diff --git a/packages/SystemUI/frp/README.md b/packages/SystemUI/frp/README.md new file mode 100644 index 000000000000..9c5bdb036c45 --- /dev/null +++ b/packages/SystemUI/frp/README.md @@ -0,0 +1,64 @@ +# kt-frp + +A functional reactive programming (FRP) library for Kotlin. + +This library is **experimental** and should not be used for general production +code. The APIs within are subject to change, and there may be bugs. + +## About FRP + +Functional reactive programming is a type of reactive programming system that +follows a set of clear and composable rules, without sacrificing consistency. +FRP exposes an API that should be familiar to those versed in Kotlin `Flow`. + +### Details for nerds + +`kt-frp` implements an applicative / monadic flavor of FRP, using a push-pull +methodology to allow for efficient updates. + +"Real" functional reactive programming should be specified with denotational +semantics ([wikipedia](https://en.wikipedia.org/wiki/Denotational_semantics)): +you can view the semantics for `kt-frp` [here](docs/semantics.md). + +## Usage + +First, stand up a new `FrpNetwork`. All reactive events and state is kept +consistent within a single network. + +``` kotlin +val coroutineScope: CoroutineScope = ... +val frpNetwork = coroutineScope.newFrpNetwork() +``` + +You can use the `FrpNetwork` to stand-up a network of reactive events and state. +Events are modeled with `TFlow` (short for "transactional flow"), and state +`TState` (short for "transactional state"). + +``` kotlin +suspend fun activate(network: FrpNetwork) { + network.activateSpec { + val input = network.mutableTFlow<Unit>() + // Launch a long-running side-effect that emits to the network + // every second. + launchEffect { + while (true) { + input.emit(Unit) + delay(1.seconds) + } + } + // Accumulate state + val count: TState<Int> = input.fold { _, i -> i + 1 } + // Observe events to perform side-effects in reaction to them + input.observe { + println("Got event ${count.sample()} at time: ${System.currentTimeMillis()}") + } + } +} +``` + +`FrpNetwork.activateSpec` will suspend indefinitely; cancelling the invocation +will tear-down all effects and obervers running within the lambda. + +## Resources + +- [Cheatsheet for those coming from Kotlin Flow](docs/flow-to-frp-cheatsheet.md) diff --git a/packages/SystemUI/frp/docs/flow-to-frp-cheatsheet.md b/packages/SystemUI/frp/docs/flow-to-frp-cheatsheet.md new file mode 100644 index 000000000000..e20f3e6c7461 --- /dev/null +++ b/packages/SystemUI/frp/docs/flow-to-frp-cheatsheet.md @@ -0,0 +1,330 @@ +# From Flows to FRP + +## Key differences + +* FRP evaluates all events (`TFlow` emissions + observers) in a transaction. + +* FRP splits `Flow` APIs into two distinct types: `TFlow` and `TState` + + * `TFlow` is roughly equivalent to `SharedFlow` w/ a replay cache that + exists for the duration of the current FRP transaction and shared with + `SharingStarted.WhileSubscribed()` + + * `TState` is roughly equivalent to `StateFlow` shared with + `SharingStarted.Eagerly`, but the current value can only be queried within + a FRP transaction, and the value is only updated at the end of the + transaction + +* FRP further divides `Flow` APIs based on how they internally use state: + + * **FrpTransactionScope:** APIs that internally query some state need to be + performed within an FRP transaction + + * this scope is available from the other scopes, and from most lambdas + passed to other FRP APIs + + * **FrpStateScope:** APIs that internally accumulate state in reaction to + events need to be performed within an FRP State scope (akin to a + `CoroutineScope`) + + * this scope is a side-effect-free subset of FrpBuildScope, and so can be + used wherever you have an FrpBuildScope + + * **FrpBuildScope:** APIs that perform external side-effects (`Flow.collect`) + need to be performed within an FRP Build scope (akin to a `CoroutineScope`) + + * this scope is available from `FrpNetwork.activateSpec { … }` + + * All other APIs can be used anywhere + +## emptyFlow() + +Use `emptyTFlow` + +``` kotlin +// this TFlow emits nothing +val noEvents: TFlow<Int> = emptyTFlow +``` + +## map { … } + +Use `TFlow.map` / `TState.map` + +``` kotlin +val anInt: TState<Int> = … +val squared: TState<Int> = anInt.map { it * it } +val messages: TFlow<String> = … +val messageLengths: TFlow<Int> = messages.map { it.size } +``` + +## filter { … } / mapNotNull { … } + +### I have a TFlow + +Use `TFlow.filter` / `TFlow.mapNotNull` + +``` kotlin +val messages: TFlow<String> = … +val nonEmpty: TFlow<String> = messages.filter { it.isNotEmpty() } +``` + +### I have a TState + +Convert the `TState` to `TFlow` using `TState.stateChanges`, then use +`TFlow.filter` / `TFlow.mapNotNull` + +If you need to convert back to `TState`, use `TFlow.hold(initialValue)` on the +result. + +``` kotlin +tState.stateChanges.filter { … }.hold(initialValue) +``` + +Note that `TFlow.hold` is only available within an `FrpStateScope` in order to +track the lifetime of the state accumulation. + +## combine(...) { … } + +### I have TStates + +Use `combine(TStates)` + +``` kotlin +val someInt: TState<Int> = … +val someString: TState<String> = … +val model: TState<MyModel> = combine(someInt, someString) { i, s -> MyModel(i, s) } +``` + +### I have TFlows + +Convert the TFlows to TStates using `TFlow.hold(initialValue)`, then use +`combine(TStates)` + +If you want the behavior of Flow.combine where nothing is emitted until each +TFlow has emitted at least once, you can use filter: + +``` kotlin +// null used as an example, can use a different sentinel if needed +combine(tFlowA.hold(null), tFlowB.hold(null)) { a, b -> + a?.let { b?.let { … } } + } + .filterNotNull() +``` + +Note that `TFlow.hold` is only available within an `FrpStateScope` in order to +track the lifetime of the state accumulation. + +#### Explanation + +`Flow.combine` always tracks the last-emitted value of each `Flow` it's +combining. This is a form of state-accumulation; internally, it collects from +each `Flow`, tracks the latest-emitted value, and when anything changes, it +re-runs the lambda to combine the latest values. + +An effect of this is that `Flow.combine` doesn't emit until each combined `Flow` +has emitted at least once. This often bites developers. As a workaround, +developers generally append `.onStart { emit(initialValue) }` to the `Flows` +that don't immediately emit. + +FRP avoids this gotcha by forcing usage of `TState` for `combine`, thus ensuring +that there is always a current value to be combined for each input. + +## collect { … } + +Use `observe { … }` + +``` kotlin +val job: Job = tFlow.observe { println("observed: $it") } +``` + +Note that `observe` is only available within an `FrpBuildScope` in order to +track the lifetime of the observer. `FrpBuildScope` can only come from a +top-level `FrpNetwork.transaction { … }`, or a sub-scope created by using a +`-Latest` operator. + +## sample(flow) { … } + +### I want to sample a TState + +Use `TState.sample()` to get the current value of a `TState`. This can be +invoked anywhere you have access to an `FrpTransactionScope`. + +``` kotlin +// the lambda passed to map receives an FrpTransactionScope, so it can invoke +// sample +tFlow.map { tState.sample() } +``` + +#### Explanation + +To keep all state-reads consistent, the current value of a TState can only be +queried within an FRP transaction, modeled with `FrpTransactionScope`. Note that +both `FrpStateScope` and `FrpBuildScope` extend `FrpTransactionScope`. + +### I want to sample a TFlow + +Convert to a `TState` by using `TFlow.hold(initialValue)`, then use `sample`. + +Note that `hold` is only available within an `FrpStateScope` in order to track +the lifetime of the state accumulation. + +## stateIn(scope, sharingStarted, initialValue) + +Use `TFlow.hold(initialValue)`. There is no need to supply a sharingStarted +argument; all states are accumulated eagerly. + +``` kotlin +val ints: TFlow<Int> = … +val lastSeenInt: TState<Int> = ints.hold(initialValue = 0) +``` + +Note that `hold` is only available within an `FrpStateScope` in order to track +the lifetime of the state accumulation (akin to the scope parameter of +`Flow.stateIn`). `FrpStateScope` can only come from a top-level +`FrpNetwork.transaction { … }`, or a sub-scope created by using a `-Latest` +operator. Also note that `FrpBuildScope` extends `FrpStateScope`. + +## distinctUntilChanged() + +Use `distinctUntilChanged` like normal. This is only available for `TFlow`; +`TStates` are already `distinctUntilChanged`. + +## merge(...) + +### I have TFlows + +Use `merge(TFlows) { … }`. The lambda argument is used to disambiguate multiple +simultaneous emissions within the same transaction. + +#### Explanation + +Under FRP's rules, a `TFlow` may only emit up to once per transaction. This +means that if we are merging two or more `TFlows` that are emitting at the same +time (within the same transaction), the resulting merged `TFlow` must emit a +single value. The lambda argument allows the developer to decide what to do in +this case. + +### I have TStates + +If `combine` doesn't satisfy your needs, you can use `TState.stateChanges` to +convert to a `TFlow`, and then `merge`. + +## conflatedCallbackFlow { … } + +Use `tFlow { … }`. + +As a shortcut, if you already have a `conflatedCallbackFlow { … }`, you can +convert it to a TFlow via `Flow.toTFlow()`. + +Note that `tFlow` is only available within an `FrpBuildScope` in order to track +the lifetime of the input registration. + +## first() + +### I have a TState + +Use `TState.sample`. + +### I have a TFlow + +Use `TFlow.nextOnly`, which works exactly like `Flow.first` but instead of +suspending it returns a `TFlow` that emits once. + +The naming is intentionally different because `first` implies that it is the +first-ever value emitted from the `Flow` (which makes sense for cold `Flows`), +whereas `nextOnly` indicates that only the next value relative to the current +transaction (the one `nextOnly` is being invoked in) will be emitted. + +Note that `nextOnly` is only available within an `FrpStateScope` in order to +track the lifetime of the state accumulation. + +## flatMapLatest { … } + +If you want to use -Latest to cancel old side-effects, similar to what the Flow +-Latest operators offer for coroutines, see `mapLatest`. + +### I have a TState… + +#### …and want to switch TStates + +Use `TState.flatMap` + +``` kotlin +val flattened = tState.flatMap { a -> getTState(a) } +``` + +#### …and want to switch TFlows + +Use `TState<TFlow<T>>.switch()` + +``` kotlin +val tFlow = tState.map { a -> getTFlow(a) }.switch() +``` + +### I have a TFlow… + +#### …and want to switch TFlows + +Use `hold` to convert to a `TState<TFlow<T>>`, then use `switch` to switch to +the latest `TFlow`. + +``` kotlin +val tFlow = tFlowOfFlows.hold(emptyTFlow).switch() +``` + +#### …and want to switch TStates + +Use `hold` to convert to a `TState<TState<T>>`, then use `flatMap` to switch to +the latest `TState`. + +``` kotlin +val tState = tFlowOfStates.hold(tStateOf(initialValue)).flatMap { it } +``` + +## mapLatest { … } / collectLatest { … } + +`FrpStateScope` and `FrpBuildScope` both provide `-Latest` operators that +automatically cancel old work when new values are emitted. + +``` kotlin +val currentModel: TState<SomeModel> = … +val mapped: TState<...> = currentModel.mapLatestBuild { model -> + effect { "new model in the house: $model" } + model.someState.observe { "someState: $it" } + val someData: TState<SomeInfo> = + getBroadcasts(model.uri) + .map { extractInfo(it) } + .hold(initialInfo) + … +} +``` + +## flowOf(...) + +### I want a TState + +Use `tStateOf(initialValue)`. + +### I want a TFlow + +Use `now.map { initialValue }` + +Note that `now` is only available within an `FrpTransactionScope`. + +#### Explanation + +`TFlows` are not cold, and so there isn't a notion of "emit this value once +there is a collector" like there is for `Flow`. The closest analog would be +`TState`, since the initial value is retained indefinitely until there is an +observer. However, it is often useful to immediately emit a value within the +current transaction, usually when using a `flatMap` or `switch`. In these cases, +using `now` explicitly models that the emission will occur within the current +transaction. + +``` kotlin +fun <T> FrpTransactionScope.tFlowOf(value: T): TFlow<T> = now.map { value } +``` + +## MutableStateFlow / MutableSharedFlow + +Use `MutableTState(frpNetwork, initialValue)` and `MutableTFlow(frpNetwork)`. diff --git a/packages/SystemUI/frp/docs/semantics.md b/packages/SystemUI/frp/docs/semantics.md new file mode 100644 index 000000000000..b533190e687d --- /dev/null +++ b/packages/SystemUI/frp/docs/semantics.md @@ -0,0 +1,225 @@ +# FRP Semantics + +`kt-frp`'s pure API is based off of the following denotational semantics +([wikipedia](https://en.wikipedia.org/wiki/Denotational_semantics)). + +The semantics model `kt-frp` types as time-varying values; by making `Time` a +first-class value, we can define a referentially-transparent API that allows us +to reason about the behavior of the pure FRP combinators. This is +implementation-agnostic; we can compare the behavior of any implementation with +expected behavior denoted by these semantics to identify bugs. + +The semantics are written in pseudo-Kotlin; places where we are deviating from +real Kotlin are noted with comments. + +``` kotlin + +sealed class Time : Comparable<Time> { + object BigBang : Time() + data class At(time: BigDecimal) : Time() + object Infinity : Time() + + override final fun compareTo(other: Time): Int = + when (this) { + BigBang -> if (other === BigBang) 0 else -1 + is At -> when (other) { + BigBang -> 1 + is At -> time.compareTo(other.time) + Infinity -> -1 + } + Infinity -> if (other === Infinity) 0 else 1 + } +} + +typealias Transactional<T> = (Time) -> T + +typealias TFlow<T> = SortedMap<Time, T> + +private fun <T> SortedMap<Time, T>.pairwise(): List<Pair<Pair<Time, T>, Pair<Time<T>>>> = + // NOTE: pretend evaluation is lazy, so that error() doesn't immediately throw + (toList() + Pair(Time.Infinity, error("no value"))).zipWithNext() + +class TState<T> internal constructor( + internal val current: Transactional<T>, + val stateChanges: TFlow<T>, +) + +val emptyTFlow: TFlow<Nothing> = emptyMap() + +fun <A, B> TFlow<A>.map(f: FrpTransactionScope.(A) -> B): TFlow<B> = + mapValues { (t, a) -> FrpTransactionScope(t).f(a) } + +fun <A> TFlow<A>.filter(f: FrpTransactionScope.(A) -> Boolean): TFlow<A> = + filter { (t, a) -> FrpTransactionScope(t).f(a) } + +fun <A> merge( + first: TFlow<A>, + second: TFlow<A>, + onCoincidence: Time.(A, A) -> A, +): TFlow<A> = + first.toMutableMap().also { result -> + second.forEach { (t, a) -> + result.merge(t, a) { f, s -> + FrpTranscationScope(t).onCoincidence(f, a) + } + } + }.toSortedMap() + +fun <A> TState<TFlow<A>>.switch(): TFlow<A> { + val truncated = listOf(Pair(Time.BigBang, current.invoke(Time.BigBang))) + + stateChanges.dropWhile { (time, _) -> time < time0 } + val events = + truncated + .pairwise() + .flatMap { ((t0, sa), (t2, _)) -> + sa.filter { (t1, a) -> t0 < t1 && t1 <= t2 } + } + return events.toSortedMap() +} + +fun <A> TState<TFlow<A>>.switchPromptly(): TFlow<A> { + val truncated = listOf(Pair(Time.BigBang, current.invoke(Time.BigBang))) + + stateChanges.dropWhile { (time, _) -> time < time0 } + val events = + truncated + .pairwise() + .flatMap { ((t0, sa), (t2, _)) -> + sa.filter { (t1, a) -> t0 <= t1 && t1 <= t2 } + } + return events.toSortedMap() +} + +typealias GroupedTFlow<K, V> = TFlow<Map<K, V>> + +fun <K, V> TFlow<Map<K, V>>.groupByKey(): GroupedTFlow<K, V> = this + +fun <K, V> GroupedTFlow<K, V>.eventsForKey(key: K): TFlow<V> = + map { m -> m[k] }.filter { it != null }.map { it!! } + +fun <A, B> TState<A>.map(f: (A) -> B): TState<B> = + TState( + current = { t -> f(current.invoke(t)) }, + stateChanges = stateChanges.map { f(it) }, + ) + +fun <A, B, C> TState<A>.combineWith( + other: TState<B>, + f: (A, B) -> C, +): TState<C> = + TState( + current = { t -> f(current.invoke(t), other.current.invoke(t)) }, + stateChanges = run { + val aChanges = + stateChanges + .map { a -> + val b = other.current.sample() + Triple(a, b, f(a, b)) + } + val bChanges = + other + .stateChanges + .map { b -> + val a = current.sample() + Triple(a, b, f(a, b)) + } + merge(aChanges, bChanges) { (a, _, _), (_, b, _) -> + Triple(a, b, f(a, b)) + } + .map { (_, _, zipped) -> zipped } + }, + ) + +fun <A> TState<TState<A>>.flatten(): TState<A> { + val changes = + stateChanges + .pairwise() + .flatMap { ((t0, oldInner), (t2, _)) -> + val inWindow = + oldInner + .stateChanges + .filter { (t1, b) -> t0 <= t1 && t1 < t2 } + if (inWindow.firstOrNull()?.time != t0) { + listOf(Pair(t0, oldInner.current.invoke(t0))) + inWindow + } else { + inWindow + } + } + return TState( + current = { t -> current.invoke(t).current.invoke(t) }, + stateChanges = changes.toSortedMap(), + ) +} + +open class FrpTranscationScope internal constructor( + internal val currentTime: Time, +) { + val now: TFlow<Unit> = + sortedMapOf(currentTime to Unit) + + fun <A> Transactional<A>.sample(): A = + invoke(currentTime) + + fun <A> TState<A>.sample(): A = + current.sample() +} + +class FrpStateScope internal constructor( + time: Time, + internal val stopTime: Time, +): FrpTransactionScope(time) { + + fun <A, B> TFlow<A>.fold( + initialValue: B, + f: FrpTransactionScope.(B, A) -> B, + ): TState<B> { + val truncated = + dropWhile { (t, _) -> t < currentTime } + .takeWhile { (t, _) -> t <= stopTime } + val folded = + truncated + .scan(Pair(currentTime, initialValue)) { (_, b) (t, a) -> + Pair(t, FrpTransactionScope(t).f(a, b)) + } + val lookup = { t1 -> + folded.lastOrNull { (t0, _) -> t0 < t1 }?.value ?: initialValue + } + return TState(lookup, folded.toSortedMap()) + } + + fun <A> TFlow<A>.hold(initialValue: A): TState<A> = + fold(initialValue) { _, a -> a } + + fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally( + initialValues: Map<K, V> + ): TState<Map<K, V>> = + fold(initialValues) { patch, map -> + val eithers = patch.map { (k, v) -> + if (v is Just) Left(k to v.value) else Right(k) + } + val adds = eithers.filterIsInstance<Left>().map { it.left } + val removes = eithers.filterIsInstance<Right>().map { it.right } + val removed: Map<K, V> = map - removes.toSet() + val updated: Map<K, V> = removed + adds + updated + } + + fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + initialTFlows: Map<K, TFlow<V>>, + ): TFlow<Map<K, V>> = + foldMapIncrementally(initialTFlows).map { it.merge() }.switch() + + fun <K, A, B> TFlow<Map<K, Maybe<A>>.mapLatestStatefulForKey( + transform: suspend FrpStateScope.(A) -> B, + ): TFlow<Map<K, Maybe<B>>> = + pairwise().map { ((t0, patch), (t1, _)) -> + patch.map { (k, ma) -> + ma.map { a -> + FrpStateScope(t0, t1).transform(a) + } + } + } + } + +} + +``` diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Combinators.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Combinators.kt new file mode 100644 index 000000000000..298c071a4229 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Combinators.kt @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.util.These +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.none +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.conflate + +/** + * Returns a [TFlow] that emits the value sampled from the [Transactional] produced by each emission + * of the original [TFlow], within the same transaction of the original emission. + */ +fun <A> TFlow<Transactional<A>>.sampleTransactionals(): TFlow<A> = map { it.sample() } + +/** @see FrpTransactionScope.sample */ +fun <A, B, C> TFlow<A>.sample( + state: TState<B>, + transform: suspend FrpTransactionScope.(A, B) -> C, +): TFlow<C> = map { transform(it, state.sample()) } + +/** @see FrpTransactionScope.sample */ +fun <A, B, C> TFlow<A>.sample( + transactional: Transactional<B>, + transform: suspend FrpTransactionScope.(A, B) -> C, +): TFlow<C> = map { transform(it, transactional.sample()) } + +/** + * Like [sample], but if [state] is changing at the time it is sampled ([stateChanges] is emitting), + * then the new value is passed to [transform]. + * + * Note that [sample] is both more performant, and safer to use with recursive definitions. You will + * generally want to use it rather than this. + * + * @see sample + */ +fun <A, B, C> TFlow<A>.samplePromptly( + state: TState<B>, + transform: suspend FrpTransactionScope.(A, B) -> C, +): TFlow<C> = + sample(state) { a, b -> These.thiz<Pair<A, B>, B>(a to b) } + .mergeWith(state.stateChanges.map { These.that(it) }) { thiz, that -> + These.both((thiz as These.This).thiz, (that as These.That).that) + } + .mapMaybe { these -> + when (these) { + // both present, transform the upstream value and the new value + is These.Both -> just(transform(these.thiz.first, these.that)) + // no upstream present, so don't perform the sample + is These.That -> none() + // just the upstream, so transform the upstream and the old value + is These.This -> just(transform(these.thiz.first, these.thiz.second)) + } + } + +/** + * Returns a [TState] containing a map with a snapshot of the current state of each [TState] in the + * original map. + */ +fun <K, A> Map<K, TState<A>>.combineValues(): TState<Map<K, A>> = + asIterable() + .map { (k, state) -> state.map { v -> k to v } } + .combine() + .map { entries -> entries.toMap() } + +/** + * Returns a cold [Flow] that, when collected, emits from this [TFlow]. [network] is needed to + * transactionally connect to / disconnect from the [TFlow] when collection starts/stops. + */ +fun <A> TFlow<A>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, emits from this [TState]. [network] is needed to + * transactionally connect to / disconnect from the [TState] when collection starts/stops. + */ +fun <A> TState<A>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [FrpSpec] in a new transaction in this + * [network], and then emits from the returned [TFlow]. + * + * When collection is cancelled, so is the [FrpSpec]. This means all ongoing work is cleaned up. + */ +@JvmName("flowSpecToColdConflatedFlow") +fun <A> FrpSpec<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { applySpec().observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [FrpSpec] in a new transaction in this + * [network], and then emits from the returned [TState]. + * + * When collection is cancelled, so is the [FrpSpec]. This means all ongoing work is cleaned up. + */ +@JvmName("stateSpecToColdConflatedFlow") +fun <A> FrpSpec<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { applySpec().observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in + * this [network], and then emits from the returned [TFlow]. + */ +@JvmName("transactionalFlowToColdConflatedFlow") +fun <A> Transactional<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { sample().observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in + * this [network], and then emits from the returned [TState]. + */ +@JvmName("transactionalStateToColdConflatedFlow") +fun <A> Transactional<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { sample().observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [FrpStateful] in a new transaction in + * this [network], and then emits from the returned [TFlow]. + * + * When collection is cancelled, so is the [FrpStateful]. This means all ongoing work is cleaned up. + */ +@JvmName("statefulFlowToColdConflatedFlow") +fun <A> FrpStateful<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { applyStateful().observe { trySend(it) } } }.conflate() + +/** + * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in + * this [network], and then emits from the returned [TState]. + * + * When collection is cancelled, so is the [FrpStateful]. This means all ongoing work is cleaned up. + */ +@JvmName("statefulStateToColdConflatedFlow") +fun <A> FrpStateful<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> = + channelFlow { network.activateSpec { applyStateful().observe { trySend(it) } } }.conflate() + +/** Return a [TFlow] that emits from the original [TFlow] only when [state] is `true`. */ +fun <A> TFlow<A>.filter(state: TState<Boolean>): TFlow<A> = filter { state.sample() } + +private fun Iterable<Boolean>.allTrue() = all { it } + +private fun Iterable<Boolean>.anyTrue() = any { it } + +/** Returns a [TState] that is `true` only when all of [states] are `true`. */ +fun allOf(vararg states: TState<Boolean>): TState<Boolean> = combine(*states) { it.allTrue() } + +/** Returns a [TState] that is `true` when any of [states] are `true`. */ +fun anyOf(vararg states: TState<Boolean>): TState<Boolean> = combine(*states) { it.anyTrue() } + +/** Returns a [TState] containing the inverse of the Boolean held by the original [TState]. */ +fun not(state: TState<Boolean>): TState<Boolean> = state.mapCheapUnsafe { !it } + +/** + * Represents a modal FRP sub-network. + * + * When [enabled][enableMode], all network modifications are applied immediately to the FRP network. + * When the returned [TFlow] emits a [FrpBuildMode], that mode is enabled and replaces this mode, + * undoing all modifications in the process (any registered [observers][FrpBuildScope.observe] are + * unregistered, and any pending [side-effects][FrpBuildScope.effect] are cancelled). + * + * Use [compiledFrpSpec] to compile and stand-up a mode graph. + * + * @see FrpStatefulMode + */ +fun interface FrpBuildMode<out A> { + /** + * Invoked when this mode is enabled. Returns a value and a [TFlow] that signals a switch to a + * new mode. + */ + suspend fun FrpBuildScope.enableMode(): Pair<A, TFlow<FrpBuildMode<A>>> +} + +/** + * Returns an [FrpSpec] that, when [applied][FrpBuildScope.applySpec], stands up a modal-transition + * graph starting with this [FrpBuildMode], automatically switching to new modes as they are + * produced. + * + * @see FrpBuildMode + */ +val <A> FrpBuildMode<A>.compiledFrpSpec: FrpSpec<TState<A>> + get() = frpSpec { + var modeChangeEvents by TFlowLoop<FrpBuildMode<A>>() + val activeMode: TState<Pair<A, TFlow<FrpBuildMode<A>>>> = + modeChangeEvents + .map { it.run { frpSpec { enableMode() } } } + .holdLatestSpec(frpSpec { enableMode() }) + modeChangeEvents = + activeMode.map { statefully { it.second.nextOnly() } }.applyLatestStateful().switch() + activeMode.map { it.first } + } + +/** + * Represents a modal FRP sub-network. + * + * When [enabled][enableMode], all state accumulation is immediately started. When the returned + * [TFlow] emits a [FrpBuildMode], that mode is enabled and replaces this mode, stopping all state + * accumulation in the process. + * + * Use [compiledStateful] to compile and stand-up a mode graph. + * + * @see FrpBuildMode + */ +fun interface FrpStatefulMode<out A> { + /** + * Invoked when this mode is enabled. Returns a value and a [TFlow] that signals a switch to a + * new mode. + */ + suspend fun FrpStateScope.enableMode(): Pair<A, TFlow<FrpStatefulMode<A>>> +} + +/** + * Returns an [FrpStateful] that, when [applied][FrpStateScope.applyStateful], stands up a + * modal-transition graph starting with this [FrpStatefulMode], automatically switching to new modes + * as they are produced. + * + * @see FrpBuildMode + */ +val <A> FrpStatefulMode<A>.compiledStateful: FrpStateful<TState<A>> + get() = statefully { + var modeChangeEvents by TFlowLoop<FrpStatefulMode<A>>() + val activeMode: TState<Pair<A, TFlow<FrpStatefulMode<A>>>> = + modeChangeEvents + .map { it.run { statefully { enableMode() } } } + .holdLatestStateful(statefully { enableMode() }) + modeChangeEvents = + activeMode.map { statefully { it.second.nextOnly() } }.applyLatestStateful().switch() + activeMode.map { it.first } + } + +/** + * Runs [spec] in this [FrpBuildScope], and then re-runs it whenever [rebuildSignal] emits. Returns + * a [TState] that holds the result of the currently-active [FrpSpec]. + */ +fun <A> FrpBuildScope.rebuildOn(rebuildSignal: TFlow<*>, spec: FrpSpec<A>): TState<A> = + rebuildSignal.map { spec }.holdLatestSpec(spec) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpBuildScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpBuildScope.kt new file mode 100644 index 000000000000..6e4c9eba90bf --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpBuildScope.kt @@ -0,0 +1,864 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.map +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.RestrictsSuspension +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.launch + +/** A function that modifies the FrpNetwork. */ +typealias FrpSpec<A> = suspend FrpBuildScope.() -> A + +/** + * Constructs an [FrpSpec]. The passed [block] will be invoked with an [FrpBuildScope] that can be + * used to perform network-building operations, including adding new inputs and outputs to the + * network, as well as all operations available in [FrpTransactionScope]. + */ +@ExperimentalFrpApi +@Suppress("NOTHING_TO_INLINE") +inline fun <A> frpSpec(noinline block: suspend FrpBuildScope.() -> A): FrpSpec<A> = block + +/** Applies the [FrpSpec] within this [FrpBuildScope]. */ +@ExperimentalFrpApi +inline operator fun <A> FrpBuildScope.invoke(block: FrpBuildScope.() -> A) = run(block) + +/** Operations that add inputs and outputs to an FRP network. */ +@ExperimentalFrpApi +@RestrictsSuspension +interface FrpBuildScope : FrpStateScope { + + /** TODO: Javadoc */ + @ExperimentalFrpApi + fun <R> deferredBuildScope(block: suspend FrpBuildScope.() -> R): FrpDeferredValue<R> + + /** TODO: Javadoc */ + @ExperimentalFrpApi fun deferredBuildScopeAction(block: suspend FrpBuildScope.() -> Unit) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * Unlike [mapLatestBuild], these modifications are not undone with each subsequent emission of + * the original [TFlow]. + * + * **NOTE:** This API does not [observe] the original [TFlow], meaning that unless the returned + * (or a downstream) [TFlow] is observed separately, [transform] will not be invoked, and no + * internal side-effects will occur. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B> + + /** + * Invokes [block] whenever this [TFlow] emits a value, allowing side-effects to be safely + * performed in reaction to the emission. + * + * Specifically, [block] is deferred to the end of the transaction, and is only actually + * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a + * -Latest combinator, for example. + * + * Shorthand for: + * ```kotlin + * tFlow.observe { effect { ... } } + * ``` + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.observe( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + block: suspend FrpEffectScope.(A) -> Unit = {}, + ): Job + + /** + * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original + * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpecs] + * immediately. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same + * key are undone (any registered [observers][observe] are unregistered, and any pending + * [side-effects][effect] are cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpSpec] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey( + initialSpecs: FrpDeferredValue<Map<K, FrpSpec<B>>>, + numKeys: Int? = null, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> + + /** + * Creates an instance of a [TFlow] with elements that are from [builder]. + * + * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the + * provided [MutableTFlow]. + * + * By default, [builder] is only running while the returned [TFlow] is being + * [observed][observe]. If you want it to run at all times, simply add a no-op observer: + * ```kotlin + * tFlow { ... }.apply { observe() } + * ``` + */ + @ExperimentalFrpApi fun <T> tFlow(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T> + + /** + * Creates an instance of a [TFlow] with elements that are emitted from [builder]. + * + * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the + * provided [MutableTFlow]. + * + * By default, [builder] is only running while the returned [TFlow] is being + * [observed][observe]. If you want it to run at all times, simply add a no-op observer: + * ```kotlin + * tFlow { ... }.apply { observe() } + * ``` + * + * In the event of backpressure, emissions are *coalesced* into batches. When a value is + * [emitted][FrpCoalescingProducerScope.emit] from [builder], it is merged into the batch via + * [coalesce]. Once the batch is consumed by the frp network in the next transaction, the batch + * is reset back to [getInitialValue]. + */ + @ExperimentalFrpApi + fun <In, Out> coalescingTFlow( + getInitialValue: () -> Out, + coalesce: (old: Out, new: In) -> Out, + builder: suspend FrpCoalescingProducerScope<In>.() -> Unit, + ): TFlow<Out> + + /** + * Creates a new [FrpBuildScope] that is a child of this one. + * + * This new scope can be manually cancelled via the returned [Job], or will be cancelled + * automatically when its parent is cancelled. Cancellation will unregister all + * [observers][observe] and cancel all scheduled [effects][effect]. + * + * The return value from [block] can be accessed via the returned [FrpDeferredValue]. + */ + @ExperimentalFrpApi fun <A> asyncScope(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job> + + // TODO: once we have context params, these can all become extensions: + + /** + * Returns a [TFlow] containing the results of applying the given [transform] function to each + * value of the original [TFlow]. + * + * Unlike [TFlow.map], [transform] can perform arbitrary asynchronous code. This code is run + * outside of the current FRP transaction; when [transform] returns, the returned value is + * emitted from the result [TFlow] in a new transaction. + * + * Shorthand for: + * ```kotlin + * tflow.mapLatestBuild { a -> asyncTFlow { transform(a) } }.flatten() + * ``` + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapAsyncLatest(transform: suspend (A) -> B): TFlow<B> = + mapLatestBuild { a -> asyncTFlow { transform(a) } }.flatten() + + /** + * Invokes [block] whenever this [TFlow] emits a value. [block] receives an [FrpBuildScope] that + * can be used to make further modifications to the FRP network, and/or perform side-effects via + * [effect]. + * + * @see observe + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.observeBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job = + mapBuild(block).observe() + + /** + * Returns a [StateFlow] whose [value][StateFlow.value] tracks the current + * [value of this TState][TState.sample], and will emit at the same rate as + * [TState.stateChanges]. + * + * Note that the [value][StateFlow.value] is not available until the *end* of the current + * transaction. If you need the current value before this time, then use [TState.sample]. + */ + @ExperimentalFrpApi + fun <A> TState<A>.toStateFlow(): StateFlow<A> { + val uninitialized = Any() + var initialValue: Any? = uninitialized + val innerStateFlow = MutableStateFlow<Any?>(uninitialized) + deferredBuildScope { + initialValue = sample() + stateChanges.observe { + innerStateFlow.value = it + initialValue = null + } + } + + @Suppress("UNCHECKED_CAST") + fun getValue(innerValue: Any?): A = + when { + innerValue !== uninitialized -> innerValue as A + initialValue !== uninitialized -> initialValue as A + else -> + error( + "Attempted to access StateFlow.value before FRP transaction has completed." + ) + } + + return object : StateFlow<A> { + override val replayCache: List<A> + get() = innerStateFlow.replayCache.map(::getValue) + + override val value: A + get() = getValue(innerStateFlow.value) + + override suspend fun collect(collector: FlowCollector<A>): Nothing { + innerStateFlow.collect { collector.emit(getValue(it)) } + } + } + } + + /** + * Returns a [SharedFlow] configured with a replay cache of size [replay] that emits the current + * [value][TState.sample] of this [TState] followed by all [stateChanges]. + */ + @ExperimentalFrpApi + fun <A> TState<A>.toSharedFlow(replay: Int = 0): SharedFlow<A> { + val result = MutableSharedFlow<A>(replay, extraBufferCapacity = 1) + deferredBuildScope { + result.tryEmit(sample()) + stateChanges.observe { a -> result.tryEmit(a) } + } + return result + } + + /** + * Returns a [SharedFlow] configured with a replay cache of size [replay] that emits values + * whenever this [TFlow] emits. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.toSharedFlow(replay: Int = 0): SharedFlow<A> { + val result = MutableSharedFlow<A>(replay, extraBufferCapacity = 1) + observe { a -> result.tryEmit(a) } + return result + } + + /** + * Returns a [TState] that holds onto the value returned by applying the most recently emitted + * [FrpSpec] from the original [TFlow], or the value returned by applying [initialSpec] if + * nothing has been emitted since it was constructed. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A> TFlow<FrpSpec<A>>.holdLatestSpec(initialSpec: FrpSpec<A>): TState<A> { + val (changes: TFlow<A>, initApplied: FrpDeferredValue<A>) = applyLatestSpec(initialSpec) + return changes.holdDeferred(initApplied) + } + + /** + * Returns a [TState] containing the value returned by applying the [FrpSpec] held by the + * original [TState]. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A> TState<FrpSpec<A>>.applyLatestSpec(): TState<A> { + val (appliedChanges: TFlow<A>, init: FrpDeferredValue<A>) = + stateChanges.applyLatestSpec(frpSpec { sample().applySpec() }) + return appliedChanges.holdDeferred(init) + } + + /** + * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original + * [TFlow]. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A> TFlow<FrpSpec<A>>.applyLatestSpec(): TFlow<A> = applyLatestSpec(frpSpec {}).first + + /** + * Returns a [TFlow] that switches to a new [TFlow] produced by [transform] every time the + * original [TFlow] emits a value. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * When the original [TFlow] emits a new value, those changes are undone (any registered + * [observers][observe] are unregistered, and any pending [effects][effect] are cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.flatMapLatestBuild( + transform: suspend FrpBuildScope.(A) -> TFlow<B> + ): TFlow<B> = mapCheap { frpSpec { transform(it) } }.applyLatestSpec().flatten() + + /** + * Returns a [TState] by applying [transform] to the value held by the original [TState]. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * When the value held by the original [TState] changes, those changes are undone (any + * registered [observers][observe] are unregistered, and any pending [effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TState<A>.flatMapLatestBuild( + transform: suspend FrpBuildScope.(A) -> TState<B> + ): TState<B> = mapLatestBuild { transform(it) }.flatten() + + /** + * Returns a [TState] that transforms the value held inside this [TState] by applying it to the + * [transform]. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * When the value held by the original [TState] changes, those changes are undone (any + * registered [observers][observe] are unregistered, and any pending [effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TState<A>.mapLatestBuild(transform: suspend FrpBuildScope.(A) -> B): TState<B> = + mapCheapUnsafe { frpSpec { transform(it) } }.applyLatestSpec() + + /** + * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original + * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpec] + * immediately. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A : Any?, B> TFlow<FrpSpec<B>>.applyLatestSpec( + initialSpec: FrpSpec<A> + ): Pair<TFlow<B>, FrpDeferredValue<A>> { + val (flow, result) = + mapCheap { spec -> mapOf(Unit to just(spec)) } + .applyLatestSpecForKey(initialSpecs = mapOf(Unit to initialSpec), numKeys = 1) + val outFlow: TFlow<B> = + flow.mapMaybe { + checkNotNull(it[Unit]) { "applyLatest: expected result, but none present in: $it" } + } + val outInit: FrpDeferredValue<A> = deferredBuildScope { + val initResult: Map<Unit, A> = result.get() + check(Unit in initResult) { + "applyLatest: expected initial result, but none present in: $initResult" + } + @Suppress("UNCHECKED_CAST") + initResult.getOrDefault(Unit) { null } as A + } + return Pair(outFlow, outInit) + } + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapLatestBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B> = + mapCheap { frpSpec { transform(it) } }.applyLatestSpec() + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValue] immediately. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapLatestBuild( + initialValue: A, + transform: suspend FrpBuildScope.(A) -> B, + ): Pair<TFlow<B>, FrpDeferredValue<B>> = + mapLatestBuildDeferred(deferredOf(initialValue), transform) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValue] immediately. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapLatestBuildDeferred( + initialValue: FrpDeferredValue<A>, + transform: suspend FrpBuildScope.(A) -> B, + ): Pair<TFlow<B>, FrpDeferredValue<B>> = + mapCheap { frpSpec { transform(it) } } + .applyLatestSpec(initialSpec = frpSpec { transform(initialValue.get()) }) + + /** + * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original + * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpecs] + * immediately. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same + * key are undone (any registered [observers][observe] are unregistered, and any pending + * [side-effects][effect] are cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpSpec] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey( + initialSpecs: Map<K, FrpSpec<B>>, + numKeys: Int? = null, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> = + applyLatestSpecForKey(deferredOf(initialSpecs), numKeys) + + /** + * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original + * [TFlow]. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same + * key are undone (any registered [observers][observe] are unregistered, and any pending + * [side-effects][effect] are cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpSpec] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey( + numKeys: Int? = null + ): TFlow<Map<K, Maybe<A>>> = + applyLatestSpecForKey<K, A, Nothing>(deferredOf(emptyMap()), numKeys).first + + /** + * Returns a [TState] containing the latest results of applying each [FrpSpec] emitted from the + * original [TFlow]. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same + * key are undone (any registered [observers][observe] are unregistered, and any pending + * [side-effects][effect] are cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpSpec] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.holdLatestSpecForKey( + initialSpecs: FrpDeferredValue<Map<K, FrpSpec<A>>>, + numKeys: Int? = null, + ): TState<Map<K, A>> { + val (changes, initialValues) = applyLatestSpecForKey(initialSpecs, numKeys) + return changes.foldMapIncrementally(initialValues) + } + + /** + * Returns a [TState] containing the latest results of applying each [FrpSpec] emitted from the + * original [TFlow]. + * + * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same + * key are undone (any registered [observers][observe] are unregistered, and any pending + * [side-effects][effect] are cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpSpec] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.holdLatestSpecForKey( + initialSpecs: Map<K, FrpSpec<A>> = emptyMap(), + numKeys: Int? = null, + ): TState<Map<K, A>> = holdLatestSpecForKey(deferredOf(initialSpecs), numKeys) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValues] immediately. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpBuildScope] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey( + initialValues: FrpDeferredValue<Map<K, A>>, + numKeys: Int? = null, + transform: suspend FrpBuildScope.(A) -> B, + ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> = + map { patch -> patch.mapValues { (_, v) -> v.map { frpSpec { transform(it) } } } } + .applyLatestSpecForKey( + deferredBuildScope { + initialValues.get().mapValues { (_, v) -> frpSpec { transform(v) } } + }, + numKeys = numKeys, + ) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValues] immediately. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpBuildScope] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey( + initialValues: Map<K, A>, + numKeys: Int? = null, + transform: suspend FrpBuildScope.(A) -> B, + ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> = + mapLatestBuildForKey(deferredOf(initialValues), numKeys, transform) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver. + * With each invocation of [transform], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpBuildScope] will be undone with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey( + numKeys: Int? = null, + transform: suspend FrpBuildScope.(A) -> B, + ): TFlow<Map<K, Maybe<B>>> = mapLatestBuildForKey(emptyMap(), numKeys, transform).first + + /** Returns a [Deferred] containing the next value to be emitted from this [TFlow]. */ + @ExperimentalFrpApi + fun <R> TFlow<R>.nextDeferred(): Deferred<R> { + lateinit var next: CompletableDeferred<R> + val job = nextOnly().observe { next.complete(it) } + next = CompletableDeferred<R>(parent = job) + return next + } + + /** Returns a [TState] that reflects the [StateFlow.value] of this [StateFlow]. */ + @ExperimentalFrpApi + fun <A> StateFlow<A>.toTState(): TState<A> { + val initial = value + return tFlow { dropWhile { it == initial }.collect { emit(it) } }.hold(initial) + } + + /** Returns a [TFlow] that emits whenever this [Flow] emits. */ + @ExperimentalFrpApi fun <A> Flow<A>.toTFlow(): TFlow<A> = tFlow { collect { emit(it) } } + + /** + * Shorthand for: + * ```kotlin + * flow.toTFlow().hold(initialValue) + * ``` + */ + @ExperimentalFrpApi + fun <A> Flow<A>.toTState(initialValue: A): TState<A> = toTFlow().hold(initialValue) + + /** + * Invokes [block] whenever this [TFlow] emits a value. [block] receives an [FrpBuildScope] that + * can be used to make further modifications to the FRP network, and/or perform side-effects via + * [effect]. + * + * With each invocation of [block], changes from the previous invocation are undone (any + * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are + * cancelled). + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.observeLatestBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job = + mapLatestBuild { block(it) }.observe() + + /** + * Invokes [block] whenever this [TFlow] emits a value, allowing side-effects to be safely + * performed in reaction to the emission. + * + * With each invocation of [block], running effects from the previous invocation are cancelled. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.observeLatest(block: suspend FrpEffectScope.(A) -> Unit = {}): Job { + var innerJob: Job? = null + return observeBuild { + innerJob?.cancel() + innerJob = effect { block(it) } + } + } + + /** + * Invokes [block] with the value held by this [TState], allowing side-effects to be safely + * performed in reaction to the state changing. + * + * With each invocation of [block], running effects from the previous invocation are cancelled. + */ + @ExperimentalFrpApi + fun <A> TState<A>.observeLatest(block: suspend FrpEffectScope.(A) -> Unit = {}): Job = + launchScope { + var innerJob = effect { block(sample()) } + stateChanges.observeBuild { + innerJob.cancel() + innerJob = effect { block(it) } + } + } + + /** + * Applies [block] to the value held by this [TState]. [block] receives an [FrpBuildScope] that + * can be used to make further modifications to the FRP network, and/or perform side-effects via + * [effect]. + * + * [block] can perform modifications to the FRP network via its [FrpBuildScope] receiver. With + * each invocation of [block], changes from the previous invocation are undone (any registered + * [observers][observe] are unregistered, and any pending [side-effects][effect] are cancelled). + */ + @ExperimentalFrpApi + fun <A> TState<A>.observeLatestBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job = + launchScope { + var innerJob: Job = launchScope { block(sample()) } + stateChanges.observeBuild { + innerJob.cancel() + innerJob = launchScope { block(it) } + } + } + + /** Applies the [FrpSpec] within this [FrpBuildScope]. */ + @ExperimentalFrpApi suspend fun <A> FrpSpec<A>.applySpec(): A = this() + + /** + * Applies the [FrpSpec] within this [FrpBuildScope], returning the result as an + * [FrpDeferredValue]. + */ + @ExperimentalFrpApi + fun <A> FrpSpec<A>.applySpecDeferred(): FrpDeferredValue<A> = deferredBuildScope { applySpec() } + + /** + * Invokes [block] on the value held in this [TState]. [block] receives an [FrpBuildScope] that + * can be used to make further modifications to the FRP network, and/or perform side-effects via + * [effect]. + */ + @ExperimentalFrpApi + fun <A> TState<A>.observeBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job = + launchScope { + block(sample()) + stateChanges.observeBuild(block) + } + + /** + * Invokes [block] with the current value of this [TState], re-invoking whenever it changes, + * allowing side-effects to be safely performed in reaction value changing. + * + * Specifically, [block] is deferred to the end of the transaction, and is only actually + * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a + * -Latest combinator, for example. + * + * If the [TState] is changing within the *current* transaction (i.e. [stateChanges] is + * presently emitting) then [block] will be invoked for the first time with the new value; + * otherwise, it will be invoked with the [current][sample] value. + */ + @ExperimentalFrpApi + fun <A> TState<A>.observe(block: suspend FrpEffectScope.(A) -> Unit = {}): Job = + now.map { sample() }.mergeWith(stateChanges) { _, new -> new }.observe { block(it) } +} + +/** + * Returns a [TFlow] that emits the result of [block] once it completes. [block] is evaluated + * outside of the current FRP transaction; when it completes, the returned [TFlow] emits in a new + * transaction. + * + * Shorthand for: + * ``` + * tFlow { emitter: MutableTFlow<A> -> + * val a = block() + * emitter.emit(a) + * } + * ``` + */ +@ExperimentalFrpApi +fun <A> FrpBuildScope.asyncTFlow(block: suspend () -> A): TFlow<A> = + tFlow { + // TODO: if block completes synchronously, it would be nice to emit within this + // transaction + emit(block()) + } + .apply { observe() } + +/** + * Performs a side-effect in a safe manner w/r/t the current FRP transaction. + * + * Specifically, [block] is deferred to the end of the current transaction, and is only actually + * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a + * -Latest combinator, for example. + * + * Shorthand for: + * ```kotlin + * now.observe { block() } + * ``` + */ +@ExperimentalFrpApi +fun FrpBuildScope.effect(block: suspend FrpEffectScope.() -> Unit): Job = now.observe { block() } + +/** + * Launches [block] in a new coroutine, returning a [Job] bound to the coroutine. + * + * This coroutine is not actually started until the *end* of the current FRP transaction. This is + * done because the current [FrpBuildScope] might be deactivated within this transaction, perhaps + * due to a -Latest combinator. If this happens, then the coroutine will never actually be started. + * + * Shorthand for: + * ```kotlin + * effect { frpCoroutineScope.launch { block() } } + * ``` + */ +@ExperimentalFrpApi +fun FrpBuildScope.launchEffect(block: suspend CoroutineScope.() -> Unit): Job = asyncEffect(block) + +/** + * Launches [block] in a new coroutine, returning the result as a [Deferred]. + * + * This coroutine is not actually started until the *end* of the current FRP transaction. This is + * done because the current [FrpBuildScope] might be deactivated within this transaction, perhaps + * due to a -Latest combinator. If this happens, then the coroutine will never actually be started. + * + * Shorthand for: + * ```kotlin + * CompletableDeferred<R>.apply { + * effect { frpCoroutineScope.launch { complete(coroutineScope { block() }) } } + * } + * .await() + * ``` + */ +@ExperimentalFrpApi +fun <R> FrpBuildScope.asyncEffect(block: suspend CoroutineScope.() -> R): Deferred<R> { + val result = CompletableDeferred<R>() + val job = now.observe { frpCoroutineScope.launch { result.complete(coroutineScope(block)) } } + val handle = job.invokeOnCompletion { result.cancel() } + result.invokeOnCompletion { + handle.dispose() + job.cancel() + } + return result +} + +/** Like [FrpBuildScope.asyncScope], but ignores the result of [block]. */ +@ExperimentalFrpApi fun FrpBuildScope.launchScope(block: FrpSpec<*>): Job = asyncScope(block).second + +/** + * Creates an instance of a [TFlow] with elements that are emitted from [builder]. + * + * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the provided + * [MutableTFlow]. + * + * By default, [builder] is only running while the returned [TFlow] is being + * [observed][FrpBuildScope.observe]. If you want it to run at all times, simply add a no-op + * observer: + * ```kotlin + * tFlow { ... }.apply { observe() } + * ``` + * + * In the event of backpressure, emissions are *coalesced* into batches. When a value is + * [emitted][FrpCoalescingProducerScope.emit] from [builder], it is merged into the batch via + * [coalesce]. Once the batch is consumed by the FRP network in the next transaction, the batch is + * reset back to [initialValue]. + */ +@ExperimentalFrpApi +fun <In, Out> FrpBuildScope.coalescingTFlow( + initialValue: Out, + coalesce: (old: Out, new: In) -> Out, + builder: suspend FrpCoalescingProducerScope<In>.() -> Unit, +): TFlow<Out> = coalescingTFlow(getInitialValue = { initialValue }, coalesce, builder) + +/** + * Creates an instance of a [TFlow] with elements that are emitted from [builder]. + * + * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the provided + * [MutableTFlow]. + * + * By default, [builder] is only running while the returned [TFlow] is being + * [observed][FrpBuildScope.observe]. If you want it to run at all times, simply add a no-op + * observer: + * ```kotlin + * tFlow { ... }.apply { observe() } + * ``` + * + * In the event of backpressure, emissions are *conflated*; any older emissions are dropped and only + * the most recent emission will be used when the FRP network is ready. + */ +@ExperimentalFrpApi +fun <T> FrpBuildScope.conflatedTFlow( + builder: suspend FrpCoalescingProducerScope<T>.() -> Unit +): TFlow<T> = + coalescingTFlow<T, Any?>(initialValue = Any(), coalesce = { _, new -> new }, builder = builder) + .mapCheap { + @Suppress("UNCHECKED_CAST") + it as T + } + +/** Scope for emitting to a [FrpBuildScope.coalescingTFlow]. */ +interface FrpCoalescingProducerScope<in T> { + /** + * Inserts [value] into the current batch, enqueueing it for emission from this [TFlow] if not + * already pending. + * + * Backpressure occurs when [emit] is called while the FRP network is currently in a + * transaction; if called multiple times, then emissions will be coalesced into a single batch + * that is then processed when the network is ready. + */ + fun emit(value: T) +} + +/** Scope for emitting to a [FrpBuildScope.tFlow]. */ +interface FrpProducerScope<in T> { + /** + * Emits a [value] to this [TFlow], suspending the caller until the FRP transaction containing + * the emission has completed. + */ + suspend fun emit(value: T) +} + +/** + * Suspends forever. Upon cancellation, runs [block]. Useful for unregistering callbacks inside of + * [FrpBuildScope.tFlow] and [FrpBuildScope.coalescingTFlow]. + */ +suspend fun awaitClose(block: () -> Unit): Nothing = + try { + awaitCancellation() + } finally { + block() + } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpEffectScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpEffectScope.kt new file mode 100644 index 000000000000..a8ec98fdc6c9 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpEffectScope.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import kotlin.coroutines.RestrictsSuspension +import kotlinx.coroutines.CoroutineScope + +/** + * Scope for external side-effects triggered by the Frp network. This still occurs within the + * context of a transaction, so general suspending calls are disallowed to prevent blocking the + * transaction. You can use [frpCoroutineScope] to [launch] new coroutines to perform long-running + * asynchronous work. This scope is alive for the duration of the containing [FrpBuildScope] that + * this side-effect scope is running in. + */ +@RestrictsSuspension +@ExperimentalFrpApi +interface FrpEffectScope : FrpTransactionScope { + /** + * A [CoroutineScope] whose lifecycle lives for as long as this [FrpEffectScope] is alive. This + * is generally until the [Job] returned by [FrpBuildScope.effect] is cancelled. + */ + @ExperimentalFrpApi val frpCoroutineScope: CoroutineScope + + /** + * A [FrpNetwork] instance that can be used to transactionally query / modify the FRP network. + * + * The lambda passed to [FrpNetwork.transact] on this instance will receive an [FrpBuildScope] + * that is lifetime-bound to this [FrpEffectScope]. Once this [FrpEffectScope] is no longer + * alive, any modifications to the FRP network performed via this [FrpNetwork] instance will be + * undone (any registered [observers][FrpBuildScope.observe] are unregistered, and any pending + * [side-effects][FrpBuildScope.effect] are cancelled). + */ + @ExperimentalFrpApi val frpNetwork: FrpNetwork +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpNetwork.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpNetwork.kt new file mode 100644 index 000000000000..acc76d93f928 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpNetwork.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.internal.BuildScopeImpl +import com.android.systemui.experimental.frp.internal.Network +import com.android.systemui.experimental.frp.internal.StateScopeImpl +import com.android.systemui.experimental.frp.internal.util.awaitCancellationAndThen +import com.android.systemui.experimental.frp.internal.util.childScope +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +/** + * Marks declarations that are still **experimental** and shouldn't be used in general production + * code. + */ +@RequiresOptIn( + message = "This API is experimental and should not be used in general production code." +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalFrpApi + +/** + * External interface to an FRP network. Can be used to make transactional queries and modifications + * to the network. + */ +@ExperimentalFrpApi +interface FrpNetwork { + /** + * Runs [block] inside of a transaction, suspending until the transaction is complete. + * + * The [FrpBuildScope] receiver exposes methods that can be used to query or modify the network. + * If the network is cancelled while the caller of [transact] is suspended, then the call will + * be cancelled. + */ + @ExperimentalFrpApi suspend fun <R> transact(block: suspend FrpTransactionScope.() -> R): R + + /** + * Activates [spec] in a transaction, suspending indefinitely. While suspended, all observers + * and long-running effects are kept alive. When cancelled, observers are unregistered and + * effects are cancelled. + */ + @ExperimentalFrpApi suspend fun activateSpec(spec: FrpSpec<*>) + + /** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */ + @ExperimentalFrpApi + fun <In, Out> coalescingMutableTFlow( + coalesce: (old: Out, new: In) -> Out, + getInitialValue: () -> Out, + ): CoalescingMutableTFlow<In, Out> + + /** Returns a [MutableTFlow] that can emit values into this [FrpNetwork]. */ + @ExperimentalFrpApi fun <T> mutableTFlow(): MutableTFlow<T> + + /** Returns a [MutableTState]. with initial state [initialValue]. */ + @ExperimentalFrpApi + fun <T> mutableTStateDeferred(initialValue: FrpDeferredValue<T>): MutableTState<T> +} + +/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */ +@ExperimentalFrpApi +fun <In, Out> FrpNetwork.coalescingMutableTFlow( + coalesce: (old: Out, new: In) -> Out, + initialValue: Out, +): CoalescingMutableTFlow<In, Out> = + coalescingMutableTFlow(coalesce, getInitialValue = { initialValue }) + +/** Returns a [MutableTState]. with initial state [initialValue]. */ +@ExperimentalFrpApi +fun <T> FrpNetwork.mutableTState(initialValue: T): MutableTState<T> = + mutableTStateDeferred(deferredOf(initialValue)) + +/** Returns a [MutableTState]. with initial state [initialValue]. */ +@ExperimentalFrpApi +fun <T> MutableTState(network: FrpNetwork, initialValue: T): MutableTState<T> = + network.mutableTState(initialValue) + +/** Returns a [MutableTFlow] that can emit values into this [FrpNetwork]. */ +@ExperimentalFrpApi +fun <T> MutableTFlow(network: FrpNetwork): MutableTFlow<T> = network.mutableTFlow() + +/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */ +@ExperimentalFrpApi +fun <In, Out> CoalescingMutableTFlow( + network: FrpNetwork, + coalesce: (old: Out, new: In) -> Out, + initialValue: Out, +): CoalescingMutableTFlow<In, Out> = network.coalescingMutableTFlow(coalesce) { initialValue } + +/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */ +@ExperimentalFrpApi +fun <In, Out> CoalescingMutableTFlow( + network: FrpNetwork, + coalesce: (old: Out, new: In) -> Out, + getInitialValue: () -> Out, +): CoalescingMutableTFlow<In, Out> = network.coalescingMutableTFlow(coalesce, getInitialValue) + +/** + * Activates [spec] in a transaction and invokes [block] with the result, suspending indefinitely. + * While suspended, all observers and long-running effects are kept alive. When cancelled, observers + * are unregistered and effects are cancelled. + */ +@ExperimentalFrpApi +suspend fun <R> FrpNetwork.activateSpec(spec: FrpSpec<R>, block: suspend (R) -> Unit) { + activateSpec { + val result = spec.applySpec() + launchEffect { block(result) } + } +} + +internal class LocalFrpNetwork( + private val network: Network, + private val scope: CoroutineScope, + private val endSignal: TFlow<Any>, +) : FrpNetwork { + override suspend fun <R> transact(block: suspend FrpTransactionScope.() -> R): R { + val result = CompletableDeferred<R>(coroutineContext[Job]) + @Suppress("DeferredResultUnused") + network.transaction { + val buildScope = + BuildScopeImpl( + stateScope = StateScopeImpl(evalScope = this, endSignal = endSignal), + coroutineScope = scope, + ) + buildScope.runInBuildScope { effect { result.complete(block()) } } + } + return result.await() + } + + override suspend fun activateSpec(spec: FrpSpec<*>) { + val job = + network + .transaction { + val buildScope = + BuildScopeImpl( + stateScope = StateScopeImpl(evalScope = this, endSignal = endSignal), + coroutineScope = scope, + ) + buildScope.runInBuildScope { launchScope(spec) } + } + .await() + awaitCancellationAndThen { job.cancel() } + } + + override fun <In, Out> coalescingMutableTFlow( + coalesce: (old: Out, new: In) -> Out, + getInitialValue: () -> Out, + ): CoalescingMutableTFlow<In, Out> = CoalescingMutableTFlow(coalesce, network, getInitialValue) + + override fun <T> mutableTFlow(): MutableTFlow<T> = MutableTFlow(network) + + override fun <T> mutableTStateDeferred(initialValue: FrpDeferredValue<T>): MutableTState<T> = + MutableTState(network, initialValue.unwrapped) +} + +/** + * Combination of an [FrpNetwork] and a [Job] that, when cancelled, will cancel the entire FRP + * network. + */ +@ExperimentalFrpApi +class RootFrpNetwork +internal constructor(private val network: Network, private val scope: CoroutineScope, job: Job) : + Job by job, FrpNetwork by LocalFrpNetwork(network, scope, emptyTFlow) + +/** Constructs a new [RootFrpNetwork] in the given [CoroutineScope]. */ +@ExperimentalFrpApi +fun CoroutineScope.newFrpNetwork( + context: CoroutineContext = EmptyCoroutineContext +): RootFrpNetwork { + val scope = childScope(context) + val network = Network(scope) + scope.launch(CoroutineName("newFrpNetwork scheduler")) { network.runInputScheduler() } + return RootFrpNetwork(network, scope, scope.coroutineContext.job) +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpScope.kt new file mode 100644 index 000000000000..a5a7977f2177 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpScope.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import kotlin.coroutines.RestrictsSuspension +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +/** Denotes [FrpScope] interfaces as [DSL markers][DslMarker]. */ +@DslMarker annotation class FrpScopeMarker + +/** + * Base scope for all FRP scopes. Used to prevent implicitly capturing other scopes from in lambdas. + */ +@FrpScopeMarker +@RestrictsSuspension +@ExperimentalFrpApi +interface FrpScope { + /** + * Returns the value held by the [FrpDeferredValue], suspending until available if necessary. + */ + @ExperimentalFrpApi + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun <A> FrpDeferredValue<A>.get(): A = suspendCancellableCoroutine { k -> + unwrapped.invokeOnCompletion { ex -> + ex?.let { k.resumeWithException(ex) } ?: k.resume(unwrapped.getCompleted()) + } + } +} + +/** + * A value that may not be immediately (synchronously) available, but is guaranteed to be available + * before this transaction is completed. + * + * @see FrpScope.get + */ +@ExperimentalFrpApi +class FrpDeferredValue<out A> internal constructor(internal val unwrapped: Deferred<A>) + +/** + * Returns the value held by this [FrpDeferredValue], or throws [IllegalStateException] if it is not + * yet available. + * + * This API is not meant for general usage within the FRP network. It is made available mainly for + * debugging and logging. You should always prefer [get][FrpScope.get] if possible. + * + * @see FrpScope.get + */ +@ExperimentalFrpApi +@OptIn(ExperimentalCoroutinesApi::class) +fun <A> FrpDeferredValue<A>.getUnsafe(): A = unwrapped.getCompleted() + +/** Returns an already-available [FrpDeferredValue] containing [value]. */ +@ExperimentalFrpApi +fun <A> deferredOf(value: A): FrpDeferredValue<A> = FrpDeferredValue(CompletableDeferred(value)) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpStateScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpStateScope.kt new file mode 100644 index 000000000000..61336f4f4608 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpStateScope.kt @@ -0,0 +1,780 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.combine as combinePure +import com.android.systemui.experimental.frp.map as mapPure +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Left +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.Right +import com.android.systemui.experimental.frp.util.WithPrev +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.map +import com.android.systemui.experimental.frp.util.none +import com.android.systemui.experimental.frp.util.partitionEithers +import com.android.systemui.experimental.frp.util.zipWith +import kotlin.coroutines.RestrictsSuspension + +typealias FrpStateful<R> = suspend FrpStateScope.() -> R + +/** + * Returns a [FrpStateful] that, when [applied][FrpStateScope.applyStateful], invokes [block] with + * the applier's [FrpStateScope]. + */ +// TODO: caching story? should each Scope have a cache of applied FrpStateful instances? +@ExperimentalFrpApi +@Suppress("NOTHING_TO_INLINE") +inline fun <A> statefully(noinline block: suspend FrpStateScope.() -> A): FrpStateful<A> = block + +/** + * Operations that accumulate state within the FRP network. + * + * State accumulation is an ongoing process that has a lifetime. Use `-Latest` combinators, such as + * [mapLatestStateful], to create smaller, nested lifecycles so that accumulation isn't running + * longer than needed. + */ +@ExperimentalFrpApi +@RestrictsSuspension +interface FrpStateScope : FrpTransactionScope { + + /** TODO */ + @ExperimentalFrpApi + // TODO: wish this could just be `deferred` but alas + fun <A> deferredStateScope(block: suspend FrpStateScope.() -> A): FrpDeferredValue<A> + + /** + * Returns a [TState] that holds onto the most recently emitted value from this [TFlow], or + * [initialValue] if nothing has been emitted since it was constructed. + * + * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s + * have been processed; this keeps the value of the [TState] consistent during the entire FRP + * transaction. + */ + @ExperimentalFrpApi fun <A> TFlow<A>.holdDeferred(initialValue: FrpDeferredValue<A>): TState<A> + + /** + * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s + * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally]. + * + * Conceptually this is equivalent to: + * ```kotlin + * fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + * initialTFlows: Map<K, TFlow<V>>, + * ): TFlow<Map<K, V>> = + * foldMapIncrementally(initialTFlows).map { it.merge() }.switch() + * ``` + * + * While the behavior is equivalent to the conceptual definition above, the implementation is + * significantly more efficient. + * + * @see merge + */ + @ExperimentalFrpApi + fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> + + /** + * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s + * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally]. + * + * Conceptually this is equivalent to: + * ```kotlin + * fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPrompt( + * initialTFlows: Map<K, TFlow<V>>, + * ): TFlow<Map<K, V>> = + * foldMapIncrementally(initialTFlows).map { it.merge() }.switchPromptly() + * ``` + * + * While the behavior is equivalent to the conceptual definition above, the implementation is + * significantly more efficient. + * + * @see merge + */ + @ExperimentalFrpApi + fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly( + initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> + + // TODO: everything below this comment can be made into extensions once we have context params + + /** + * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s + * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally]. + * + * Conceptually this is equivalent to: + * ```kotlin + * fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + * initialTFlows: Map<K, TFlow<V>>, + * ): TFlow<Map<K, V>> = + * foldMapIncrementally(initialTFlows).map { it.merge() }.switch() + * ``` + * + * While the behavior is equivalent to the conceptual definition above, the implementation is + * significantly more efficient. + * + * @see merge + */ + @ExperimentalFrpApi + fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + initialTFlows: Map<K, TFlow<V>> = emptyMap() + ): TFlow<Map<K, V>> = mergeIncrementally(deferredOf(initialTFlows)) + + /** + * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s + * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally]. + * + * Conceptually this is equivalent to: + * ```kotlin + * fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPrompt( + * initialTFlows: Map<K, TFlow<V>>, + * ): TFlow<Map<K, V>> = + * foldMapIncrementally(initialTFlows).map { it.merge() }.switchPromptly() + * ``` + * + * While the behavior is equivalent to the conceptual definition above, the implementation is + * significantly more efficient. + * + * @see merge + */ + @ExperimentalFrpApi + fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly( + initialTFlows: Map<K, TFlow<V>> = emptyMap() + ): TFlow<Map<K, V>> = mergeIncrementallyPromptly(deferredOf(initialTFlows)) + + /** Applies the [FrpStateful] within this [FrpStateScope]. */ + @ExperimentalFrpApi suspend fun <A> FrpStateful<A>.applyStateful(): A = this() + + /** + * Applies the [FrpStateful] within this [FrpStateScope], returning the result as an + * [FrpDeferredValue]. + */ + @ExperimentalFrpApi + fun <A> FrpStateful<A>.applyStatefulDeferred(): FrpDeferredValue<A> = deferredStateScope { + applyStateful() + } + + /** + * Returns a [TState] that holds onto the most recently emitted value from this [TFlow], or + * [initialValue] if nothing has been emitted since it was constructed. + * + * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s + * have been processed; this keeps the value of the [TState] consistent during the entire FRP + * transaction. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.hold(initialValue: A): TState<A> = holdDeferred(deferredOf(initialValue)) + + /** + * Returns a [TFlow] the emits the result of applying [FrpStatefuls][FrpStateful] emitted from + * the original [TFlow]. + * + * Unlike [applyLatestStateful], state accumulation is not stopped with each subsequent emission + * of the original [TFlow]. + */ + @ExperimentalFrpApi fun <A> TFlow<FrpStateful<A>>.applyStatefuls(): TFlow<A> + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. Unlike + * [mapLatestStateful], accumulation is not stopped with each subsequent emission of the + * original [TFlow]. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapStateful(transform: suspend FrpStateScope.(A) -> B): TFlow<B> = + mapPure { statefully { transform(it) } }.applyStatefuls() + + /** + * Returns a [TState] the holds the result of applying the [FrpStateful] held by the original + * [TState]. + * + * Unlike [applyLatestStateful], state accumulation is not stopped with each state change. + */ + @ExperimentalFrpApi + fun <A> TState<FrpStateful<A>>.applyStatefuls(): TState<A> = + stateChanges + .applyStatefuls() + .holdDeferred(initialValue = deferredStateScope { sampleDeferred().get()() }) + + /** Returns a [TFlow] that switches to the [TFlow] emitted by the original [TFlow]. */ + @ExperimentalFrpApi fun <A> TFlow<TFlow<A>>.flatten() = hold(emptyTFlow).switch() + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each + * invocation of [transform], state accumulation from previous invocation is stopped. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapLatestStateful(transform: suspend FrpStateScope.(A) -> B): TFlow<B> = + mapPure { statefully { transform(it) } }.applyLatestStateful() + + /** + * Returns a [TFlow] that switches to a new [TFlow] produced by [transform] every time the + * original [TFlow] emits a value. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each + * invocation of [transform], state accumulation from previous invocation is stopped. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.flatMapLatestStateful( + transform: suspend FrpStateScope.(A) -> TFlow<B> + ): TFlow<B> = mapLatestStateful(transform).flatten() + + /** + * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the + * original [TFlow]. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] is stopped. + */ + @ExperimentalFrpApi + fun <A> TFlow<FrpStateful<A>>.applyLatestStateful(): TFlow<A> = applyLatestStateful {}.first + + /** + * Returns a [TState] containing the value returned by applying the [FrpStateful] held by the + * original [TState]. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] is stopped. + */ + @ExperimentalFrpApi + fun <A> TState<FrpStateful<A>>.applyLatestStateful(): TState<A> { + val (changes, init) = stateChanges.applyLatestStateful { sample()() } + return changes.holdDeferred(init) + } + + /** + * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init] + * immediately. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] is stopped. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<FrpStateful<B>>.applyLatestStateful( + init: FrpStateful<A> + ): Pair<TFlow<B>, FrpDeferredValue<A>> { + val (flow, result) = + mapCheap { spec -> mapOf(Unit to just(spec)) } + .applyLatestStatefulForKey(init = mapOf(Unit to init), numKeys = 1) + val outFlow: TFlow<B> = + flow.mapMaybe { + checkNotNull(it[Unit]) { "applyLatest: expected result, but none present in: $it" } + } + val outInit: FrpDeferredValue<A> = deferredTransactionScope { + val initResult: Map<Unit, A> = result.get() + check(Unit in initResult) { + "applyLatest: expected initial result, but none present in: $initResult" + } + @Suppress("UNCHECKED_CAST") + initResult.getOrDefault(Unit) { null } as A + } + return Pair(outFlow, outInit) + } + + /** + * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init] + * immediately. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateful] will be stopped with no replacement. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] with the same key is stopped. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey( + init: FrpDeferredValue<Map<K, FrpStateful<B>>>, + numKeys: Int? = null, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> + + /** + * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init] + * immediately. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] with the same key is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateful] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey( + init: Map<K, FrpStateful<B>>, + numKeys: Int? = null, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> = + applyLatestStatefulForKey(deferredOf(init), numKeys) + + /** + * Returns a [TState] containing the latest results of applying each [FrpStateful] emitted from + * the original [TFlow]. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] with the same key is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateful] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.holdLatestStatefulForKey( + init: FrpDeferredValue<Map<K, FrpStateful<A>>>, + numKeys: Int? = null, + ): TState<Map<K, A>> { + val (changes, initialValues) = applyLatestStatefulForKey(init, numKeys) + return changes.foldMapIncrementally(initialValues) + } + + /** + * Returns a [TState] containing the latest results of applying each [FrpStateful] emitted from + * the original [TFlow]. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] with the same key is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateful] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.holdLatestStatefulForKey( + init: Map<K, FrpStateful<A>> = emptyMap(), + numKeys: Int? = null, + ): TState<Map<K, A>> = holdLatestStatefulForKey(deferredOf(init), numKeys) + + /** + * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init] + * immediately. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] with the same key is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateful] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey( + numKeys: Int? = null + ): TFlow<Map<K, Maybe<A>>> = + applyLatestStatefulForKey(init = emptyMap<K, FrpStateful<*>>(), numKeys = numKeys).first + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValues] immediately. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each + * invocation of [transform], state accumulation from previous invocation is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateScope] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey( + initialValues: FrpDeferredValue<Map<K, A>>, + numKeys: Int? = null, + transform: suspend FrpStateScope.(A) -> B, + ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> = + mapPure { patch -> patch.mapValues { (_, v) -> v.map { statefully { transform(it) } } } } + .applyLatestStatefulForKey( + deferredStateScope { + initialValues.get().mapValues { (_, v) -> statefully { transform(v) } } + }, + numKeys = numKeys, + ) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to + * [initialValues] immediately. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each + * invocation of [transform], state accumulation from previous invocation is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateScope] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey( + initialValues: Map<K, A>, + numKeys: Int? = null, + transform: suspend FrpStateScope.(A) -> B, + ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> = + mapLatestStatefulForKey(deferredOf(initialValues), numKeys, transform) + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow]. + * + * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each + * invocation of [transform], state accumulation from previous invocation is stopped. + * + * If the [Maybe] contained within the value for an associated key is [none], then the + * previously-active [FrpStateScope] will be stopped with no replacement. + */ + @ExperimentalFrpApi + fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey( + numKeys: Int? = null, + transform: suspend FrpStateScope.(A) -> B, + ): TFlow<Map<K, Maybe<B>>> = mapLatestStatefulForKey(emptyMap(), numKeys, transform).first + + /** + * Returns a [TFlow] that will only emit the next event of the original [TFlow], and then will + * act as [emptyTFlow]. + * + * If the original [TFlow] is emitting an event at this exact time, then it will be the only + * even emitted from the result [TFlow]. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.nextOnly(): TFlow<A> = + if (this === emptyTFlow) { + this + } else { + TFlowLoop<A>().also { + it.loopback = it.mapCheap { emptyTFlow }.hold(this@nextOnly).switch() + } + } + + /** Returns a [TFlow] that skips the next emission of the original [TFlow]. */ + @ExperimentalFrpApi + fun <A> TFlow<A>.skipNext(): TFlow<A> = + if (this === emptyTFlow) { + this + } else { + nextOnly().mapCheap { this@skipNext }.hold(emptyTFlow).switch() + } + + /** + * Returns a [TFlow] that emits values from the original [TFlow] up until [stop] emits a value. + * + * If the original [TFlow] emits at the same time as [stop], then the returned [TFlow] will emit + * that value. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.takeUntil(stop: TFlow<*>): TFlow<A> = + if (stop === emptyTFlow) { + this + } else { + stop.mapCheap { emptyTFlow }.nextOnly().hold(this).switch() + } + + /** + * Invokes [stateful] in a new [FrpStateScope] that is a child of this one. + * + * This new scope is stopped when [stop] first emits a value, or when the parent scope is + * stopped. Stopping will end all state accumulation; any [TStates][TState] returned from this + * scope will no longer update. + */ + @ExperimentalFrpApi + fun <A> childStateScope(stop: TFlow<*>, stateful: FrpStateful<A>): FrpDeferredValue<A> { + val (_, init: FrpDeferredValue<Map<Unit, A>>) = + stop + .nextOnly() + .mapPure { mapOf(Unit to none<FrpStateful<A>>()) } + .applyLatestStatefulForKey(init = mapOf(Unit to stateful), numKeys = 1) + return deferredStateScope { init.get().getValue(Unit) } + } + + /** + * Returns a [TFlow] that emits values from the original [TFlow] up to and including a value is + * emitted that satisfies [predicate]. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.takeUntil(predicate: suspend FrpTransactionScope.(A) -> Boolean): TFlow<A> = + takeUntil(filter(predicate)) + + /** + * Returns a [TState] that is incrementally updated when this [TFlow] emits a value, by applying + * [transform] to both the emitted value and the currently tracked state. + * + * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s + * have been processed; this keeps the value of the [TState] consistent during the entire FRP + * transaction. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.fold( + initialValue: B, + transform: suspend FrpTransactionScope.(A, B) -> B, + ): TState<B> { + lateinit var state: TState<B> + return mapPure { a -> transform(a, state.sample()) }.hold(initialValue).also { state = it } + } + + /** + * Returns a [TState] that is incrementally updated when this [TFlow] emits a value, by applying + * [transform] to both the emitted value and the currently tracked state. + * + * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s + * have been processed; this keeps the value of the [TState] consistent during the entire FRP + * transaction. + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.foldDeferred( + initialValue: FrpDeferredValue<B>, + transform: suspend FrpTransactionScope.(A, B) -> B, + ): TState<B> { + lateinit var state: TState<B> + return mapPure { a -> transform(a, state.sample()) } + .holdDeferred(initialValue) + .also { state = it } + } + + /** + * Returns a [TState] that holds onto the result of applying the most recently emitted + * [FrpStateful] this [TFlow], or [init] if nothing has been emitted since it was constructed. + * + * When each [FrpStateful] is applied, state accumulation from the previously-active + * [FrpStateful] is stopped. + * + * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s + * have been processed; this keeps the value of the [TState] consistent during the entire FRP + * transaction. + * + * Shorthand for: + * ```kotlin + * val (changes, initApplied) = applyLatestStateful(init) + * return changes.toTStateDeferred(initApplied) + * ``` + */ + @ExperimentalFrpApi + fun <A> TFlow<FrpStateful<A>>.holdLatestStateful(init: FrpStateful<A>): TState<A> { + val (changes, initApplied) = applyLatestStateful(init) + return changes.holdDeferred(initApplied) + } + + /** + * Returns a [TFlow] that emits the two most recent emissions from the original [TFlow]. + * [initialValue] is used as the previous value for the first emission. + * + * Shorthand for `sample(hold(init)) { new, old -> Pair(old, new) }` + */ + @ExperimentalFrpApi + fun <S, T : S> TFlow<T>.pairwise(initialValue: S): TFlow<WithPrev<S, T>> { + val previous = hold(initialValue) + return mapCheap { new -> WithPrev(previousValue = previous.sample(), newValue = new) } + } + + /** + * Returns a [TFlow] that emits the two most recent emissions from the original [TFlow]. Note + * that the returned [TFlow] will not emit until the original [TFlow] has emitted twice. + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.pairwise(): TFlow<WithPrev<A, A>> = + mapCheap { just(it) } + .pairwise(none) + .mapMaybe { (prev, next) -> prev.zipWith(next, ::WithPrev) } + + /** + * Returns a [TState] that holds both the current and previous values of the original [TState]. + * [initialPreviousValue] is used as the first previous value. + * + * Shorthand for `sample(hold(init)) { new, old -> Pair(old, new) }` + */ + @ExperimentalFrpApi + fun <S, T : S> TState<T>.pairwise(initialPreviousValue: S): TState<WithPrev<S, T>> = + stateChanges + .pairwise(initialPreviousValue) + .holdDeferred(deferredTransactionScope { WithPrev(initialPreviousValue, sample()) }) + + /** + * Returns a [TState] holding a [Map] that is updated incrementally whenever this emits a value. + * + * The value emitted is used as a "patch" for the tracked [Map]; for each key [K] in the emitted + * map, an associated value of [Just] will insert or replace the value in the tracked [Map], and + * an associated value of [none] will remove the key from the tracked [Map]. + */ + @ExperimentalFrpApi + fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally( + initialValues: FrpDeferredValue<Map<K, V>> + ): TState<Map<K, V>> = + foldDeferred(initialValues) { patch, map -> + val (adds: List<Pair<K, V>>, removes: List<K>) = + patch + .asSequence() + .map { (k, v) -> if (v is Just) Left(k to v.value) else Right(k) } + .partitionEithers() + val removed: Map<K, V> = map - removes.toSet() + val updated: Map<K, V> = removed + adds + updated + } + + /** + * Returns a [TState] holding a [Map] that is updated incrementally whenever this emits a value. + * + * The value emitted is used as a "patch" for the tracked [Map]; for each key [K] in the emitted + * map, an associated value of [Just] will insert or replace the value in the tracked [Map], and + * an associated value of [none] will remove the key from the tracked [Map]. + */ + @ExperimentalFrpApi + fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally( + initialValues: Map<K, V> = emptyMap() + ): TState<Map<K, V>> = foldMapIncrementally(deferredOf(initialValues)) + + /** + * Returns a [TFlow] that wraps each emission of the original [TFlow] into an [IndexedValue], + * containing the emitted value and its index (starting from zero). + * + * Shorthand for: + * ``` + * val index = fold(0) { _, oldIdx -> oldIdx + 1 } + * sample(index) { a, idx -> IndexedValue(idx, a) } + * ``` + */ + @ExperimentalFrpApi + fun <A> TFlow<A>.withIndex(): TFlow<IndexedValue<A>> { + val index = fold(0) { _, old -> old + 1 } + return sample(index) { a, idx -> IndexedValue(idx, a) } + } + + /** + * Returns a [TFlow] containing the results of applying [transform] to each value of the + * original [TFlow] and its index (starting from zero). + * + * Shorthand for: + * ``` + * withIndex().map { (idx, a) -> transform(idx, a) } + * ``` + */ + @ExperimentalFrpApi + fun <A, B> TFlow<A>.mapIndexed(transform: suspend FrpTransactionScope.(Int, A) -> B): TFlow<B> { + val index = fold(0) { _, i -> i + 1 } + return sample(index) { a, idx -> transform(idx, a) } + } + + /** Returns a [TFlow] where all subsequent repetitions of the same value are filtered out. */ + @ExperimentalFrpApi + fun <A> TFlow<A>.distinctUntilChanged(): TFlow<A> { + val state: TState<Any?> = hold(Any()) + return filter { it != state.sample() } + } + + /** + * Returns a new [TFlow] that emits at the same rate as the original [TFlow], but combines the + * emitted value with the most recent emission from [other] using [transform]. + * + * Note that the returned [TFlow] will not emit anything until [other] has emitted at least one + * value. + */ + @ExperimentalFrpApi + fun <A, B, C> TFlow<A>.sample( + other: TFlow<B>, + transform: suspend FrpTransactionScope.(A, B) -> C, + ): TFlow<C> { + val state = other.mapCheap { just(it) }.hold(none) + return sample(state) { a, b -> b.map { transform(a, it) } }.filterJust() + } + + /** + * Returns a [TState] that samples the [Transactional] held by the given [TState] within the + * same transaction that the state changes. + */ + @ExperimentalFrpApi + fun <A> TState<Transactional<A>>.sampleTransactionals(): TState<A> = + stateChanges + .sampleTransactionals() + .holdDeferred(deferredTransactionScope { sample().sample() }) + + /** + * Returns a [TState] that transforms the value held inside this [TState] by applying it to the + * given function [transform]. + */ + @ExperimentalFrpApi + fun <A, B> TState<A>.map(transform: suspend FrpTransactionScope.(A) -> B): TState<B> = + mapPure { transactionally { transform(it) } }.sampleTransactionals() + + /** + * Returns a [TState] whose value is generated with [transform] by combining the current values + * of each given [TState]. + * + * @see TState.combineWith + */ + @ExperimentalFrpApi + fun <A, B, Z> combine( + stateA: TState<A>, + stateB: TState<B>, + transform: suspend FrpTransactionScope.(A, B) -> Z, + ): TState<Z> = + com.android.systemui.experimental.frp + .combine(stateA, stateB) { a, b -> transactionally { transform(a, b) } } + .sampleTransactionals() + + /** + * Returns a [TState] whose value is generated with [transform] by combining the current values + * of each given [TState]. + * + * @see TState.combineWith + */ + @ExperimentalFrpApi + fun <A, B, C, D, Z> combine( + stateA: TState<A>, + stateB: TState<B>, + stateC: TState<C>, + stateD: TState<D>, + transform: suspend FrpTransactionScope.(A, B, C, D) -> Z, + ): TState<Z> = + com.android.systemui.experimental.frp + .combine(stateA, stateB, stateC, stateD) { a, b, c, d -> + transactionally { transform(a, b, c, d) } + } + .sampleTransactionals() + + /** Returns a [TState] by applying [transform] to the value held by the original [TState]. */ + @ExperimentalFrpApi + fun <A, B> TState<A>.flatMap( + transform: suspend FrpTransactionScope.(A) -> TState<B> + ): TState<B> = mapPure { transactionally { transform(it) } }.sampleTransactionals().flatten() + + /** + * Returns a [TState] whose value is generated with [transform] by combining the current values + * of each given [TState]. + * + * @see TState.combineWith + */ + @ExperimentalFrpApi + fun <A, Z> combine( + vararg states: TState<A>, + transform: suspend FrpTransactionScope.(List<A>) -> Z, + ): TState<Z> = combinePure(*states).map(transform) + + /** + * Returns a [TState] whose value is generated with [transform] by combining the current values + * of each given [TState]. + * + * @see TState.combineWith + */ + @ExperimentalFrpApi + fun <A, Z> Iterable<TState<A>>.combine( + transform: suspend FrpTransactionScope.(List<A>) -> Z + ): TState<Z> = combinePure().map(transform) + + /** + * Returns a [TState] by combining the values held inside the given [TState]s by applying them + * to the given function [transform]. + */ + @ExperimentalFrpApi + fun <A, B, C> TState<A>.combineWith( + other: TState<B>, + transform: suspend FrpTransactionScope.(A, B) -> C, + ): TState<C> = combine(this, other, transform) +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpTransactionScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpTransactionScope.kt new file mode 100644 index 000000000000..b0b9dbcbe8c1 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/FrpTransactionScope.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import kotlin.coroutines.RestrictsSuspension + +/** + * FRP operations that are available while a transaction is active. + * + * These operations do not accumulate state, which makes [FrpTransactionScope] weaker than + * [FrpStateScope], but allows them to be used in more places. + */ +@ExperimentalFrpApi +@RestrictsSuspension +interface FrpTransactionScope : FrpScope { + + /** + * Returns the current value of this [Transactional] as a [FrpDeferredValue]. + * + * @see sample + */ + @ExperimentalFrpApi fun <A> Transactional<A>.sampleDeferred(): FrpDeferredValue<A> + + /** + * Returns the current value of this [TState] as a [FrpDeferredValue]. + * + * @see sample + */ + @ExperimentalFrpApi fun <A> TState<A>.sampleDeferred(): FrpDeferredValue<A> + + /** TODO */ + @ExperimentalFrpApi + fun <A> deferredTransactionScope( + block: suspend FrpTransactionScope.() -> A + ): FrpDeferredValue<A> + + /** A [TFlow] that emits once, within this transaction, and then never again. */ + @ExperimentalFrpApi val now: TFlow<Unit> + + /** + * Returns the current value held by this [TState]. Guaranteed to be consistent within the same + * transaction. + */ + @ExperimentalFrpApi suspend fun <A> TState<A>.sample(): A = sampleDeferred().get() + + /** + * Returns the current value held by this [Transactional]. Guaranteed to be consistent within + * the same transaction. + */ + @ExperimentalFrpApi suspend fun <A> Transactional<A>.sample(): A = sampleDeferred().get() +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TFlow.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TFlow.kt new file mode 100644 index 000000000000..cca6c9a623f2 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TFlow.kt @@ -0,0 +1,560 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.internal.DemuxImpl +import com.android.systemui.experimental.frp.internal.Init +import com.android.systemui.experimental.frp.internal.InitScope +import com.android.systemui.experimental.frp.internal.InputNode +import com.android.systemui.experimental.frp.internal.Network +import com.android.systemui.experimental.frp.internal.NoScope +import com.android.systemui.experimental.frp.internal.TFlowImpl +import com.android.systemui.experimental.frp.internal.activated +import com.android.systemui.experimental.frp.internal.cached +import com.android.systemui.experimental.frp.internal.constInit +import com.android.systemui.experimental.frp.internal.filterNode +import com.android.systemui.experimental.frp.internal.init +import com.android.systemui.experimental.frp.internal.map +import com.android.systemui.experimental.frp.internal.mapImpl +import com.android.systemui.experimental.frp.internal.mapMaybeNode +import com.android.systemui.experimental.frp.internal.mergeNodes +import com.android.systemui.experimental.frp.internal.mergeNodesLeft +import com.android.systemui.experimental.frp.internal.neverImpl +import com.android.systemui.experimental.frp.internal.switchDeferredImplSingle +import com.android.systemui.experimental.frp.internal.switchPromptImpl +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.util.Either +import com.android.systemui.experimental.frp.util.Left +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.Right +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.map +import com.android.systemui.experimental.frp.util.toMaybe +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KProperty +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** A series of values of type [A] available at discrete points in time. */ +@ExperimentalFrpApi +sealed class TFlow<out A> { + companion object { + /** A [TFlow] with no values. */ + val empty: TFlow<Nothing> = EmptyFlow + } +} + +/** A [TFlow] with no values. */ +@ExperimentalFrpApi val emptyTFlow: TFlow<Nothing> = TFlow.empty + +/** + * A forward-reference to a [TFlow]. Useful for recursive definitions. + * + * This reference can be used like a standard [TFlow], but will hold up evaluation of the FRP + * network until the [loopback] reference is set. + */ +@ExperimentalFrpApi +class TFlowLoop<A> : TFlow<A>() { + private val deferred = CompletableDeferred<TFlow<A>>() + + internal val init: Init<TFlowImpl<A>> = + init(name = null) { deferred.await().init.connect(evalScope = this) } + + /** The [TFlow] this reference is referring to. */ + @ExperimentalFrpApi + var loopback: TFlow<A>? = null + set(value) { + value?.let { + check(deferred.complete(value)) { "TFlowLoop.loopback has already been set." } + field = value + } + } + + operator fun getValue(thisRef: Any?, property: KProperty<*>): TFlow<A> = this + + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: TFlow<A>) { + loopback = value + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +/** TODO */ +@ExperimentalFrpApi fun <A> Lazy<TFlow<A>>.defer(): TFlow<A> = deferInline { value } + +/** TODO */ +@ExperimentalFrpApi +fun <A> FrpDeferredValue<TFlow<A>>.defer(): TFlow<A> = deferInline { unwrapped.await() } + +/** TODO */ +@ExperimentalFrpApi +fun <A> deferTFlow(block: suspend FrpScope.() -> TFlow<A>): TFlow<A> = deferInline { + NoScope.runInFrpScope(block) +} + +/** Returns a [TFlow] that emits the new value of this [TState] when it changes. */ +@ExperimentalFrpApi +val <A> TState<A>.stateChanges: TFlow<A> + get() = TFlowInit(init(name = null) { init.connect(evalScope = this).changes }) + +/** + * Returns a [TFlow] that contains only the [just] results of applying [transform] to each value of + * the original [TFlow]. + * + * @see mapNotNull + */ +@ExperimentalFrpApi +fun <A, B> TFlow<A>.mapMaybe(transform: suspend FrpTransactionScope.(A) -> Maybe<B>): TFlow<B> { + val pulse = + mapMaybeNode({ init.connect(evalScope = this) }) { runInTransactionScope { transform(it) } } + return TFlowInit(constInit(name = null, pulse)) +} + +/** + * Returns a [TFlow] that contains only the non-null results of applying [transform] to each value + * of the original [TFlow]. + * + * @see mapMaybe + */ +@ExperimentalFrpApi +fun <A, B> TFlow<A>.mapNotNull(transform: suspend FrpTransactionScope.(A) -> B?): TFlow<B> = + mapMaybe { + transform(it).toMaybe() + } + +/** Returns a [TFlow] containing only values of the original [TFlow] that are not null. */ +@ExperimentalFrpApi fun <A> TFlow<A?>.filterNotNull(): TFlow<A> = mapNotNull { it } + +/** Shorthand for `mapNotNull { it as? A }`. */ +@ExperimentalFrpApi +inline fun <reified A> TFlow<*>.filterIsInstance(): TFlow<A> = mapNotNull { it as? A } + +/** Shorthand for `mapMaybe { it }`. */ +@ExperimentalFrpApi fun <A> TFlow<Maybe<A>>.filterJust(): TFlow<A> = mapMaybe { it } + +/** + * Returns a [TFlow] containing the results of applying [transform] to each value of the original + * [TFlow]. + */ +@ExperimentalFrpApi +fun <A, B> TFlow<A>.map(transform: suspend FrpTransactionScope.(A) -> B): TFlow<B> { + val mapped: TFlowImpl<B> = + mapImpl({ init.connect(evalScope = this) }) { a -> runInTransactionScope { transform(a) } } + return TFlowInit(constInit(name = null, mapped.cached())) +} + +/** + * Like [map], but the emission is not cached during the transaction. Use only if [transform] is + * fast and pure. + * + * @see map + */ +@ExperimentalFrpApi +fun <A, B> TFlow<A>.mapCheap(transform: suspend FrpTransactionScope.(A) -> B): TFlow<B> = + TFlowInit( + constInit( + name = null, + mapImpl({ init.connect(evalScope = this) }) { a -> + runInTransactionScope { transform(a) } + }, + ) + ) + +/** + * Returns a [TFlow] that invokes [action] before each value of the original [TFlow] is emitted. + * Useful for logging and debugging. + * + * ``` + * pulse.onEach { foo(it) } == pulse.map { foo(it); it } + * ``` + * + * Note that the side effects performed in [onEach] are only performed while the resulting [TFlow] + * is connected to an output of the FRP network. If your goal is to reliably perform side effects in + * response to a [TFlow], use the output combinators available in [FrpBuildScope], such as + * [FrpBuildScope.toSharedFlow] or [FrpBuildScope.observe]. + */ +@ExperimentalFrpApi +fun <A> TFlow<A>.onEach(action: suspend FrpTransactionScope.(A) -> Unit): TFlow<A> = map { + action(it) + it +} + +/** + * Returns a [TFlow] containing only values of the original [TFlow] that satisfy the given + * [predicate]. + */ +@ExperimentalFrpApi +fun <A> TFlow<A>.filter(predicate: suspend FrpTransactionScope.(A) -> Boolean): TFlow<A> { + val pulse = + filterNode({ init.connect(evalScope = this) }) { runInTransactionScope { predicate(it) } } + return TFlowInit(constInit(name = null, pulse.cached())) +} + +/** + * Splits a [TFlow] of pairs into a pair of [TFlows][TFlow], where each returned [TFlow] emits half + * of the original. + * + * Shorthand for: + * ```kotlin + * val lefts = map { it.first } + * val rights = map { it.second } + * return Pair(lefts, rights) + * ``` + */ +@ExperimentalFrpApi +fun <A, B> TFlow<Pair<A, B>>.unzip(): Pair<TFlow<A>, TFlow<B>> { + val lefts = map { it.first } + val rights = map { it.second } + return lefts to rights +} + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from both. + * + * Because [TFlow]s can only emit one value per transaction, the provided [transformCoincidence] + * function is used to combine coincident emissions to produce the result value to be emitted by the + * merged [TFlow]. + */ +@ExperimentalFrpApi +fun <A> TFlow<A>.mergeWith( + other: TFlow<A>, + transformCoincidence: suspend FrpTransactionScope.(A, A) -> A = { a, _ -> a }, +): TFlow<A> { + val node = + mergeNodes( + getPulse = { init.connect(evalScope = this) }, + getOther = { other.init.connect(evalScope = this) }, + ) { a, b -> + runInTransactionScope { transformCoincidence(a, b) } + } + return TFlowInit(constInit(name = null, node)) +} + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. All coincident + * emissions are collected into the emitted [List], preserving the input ordering. + * + * @see mergeWith + * @see mergeLeft + */ +@ExperimentalFrpApi +fun <A> merge(vararg flows: TFlow<A>): TFlow<List<A>> = flows.asIterable().merge() + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. In the case of + * coincident emissions, the emission from the left-most [TFlow] is emitted. + * + * @see merge + */ +@ExperimentalFrpApi +fun <A> mergeLeft(vararg flows: TFlow<A>): TFlow<A> = flows.asIterable().mergeLeft() + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. + * + * Because [TFlow]s can only emit one value per transaction, the provided [transformCoincidence] + * function is used to combine coincident emissions to produce the result value to be emitted by the + * merged [TFlow]. + */ +// TODO: can be optimized to avoid creating the intermediate list +fun <A> merge(vararg flows: TFlow<A>, transformCoincidence: (A, A) -> A): TFlow<A> = + merge(*flows).map { l -> l.reduce(transformCoincidence) } + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. All coincident + * emissions are collected into the emitted [List], preserving the input ordering. + * + * @see mergeWith + * @see mergeLeft + */ +@ExperimentalFrpApi +fun <A> Iterable<TFlow<A>>.merge(): TFlow<List<A>> = + TFlowInit(constInit(name = null, mergeNodes { map { it.init.connect(evalScope = this) } })) + +/** + * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. In the case of + * coincident emissions, the emission from the left-most [TFlow] is emitted. + * + * @see merge + */ +@ExperimentalFrpApi +fun <A> Iterable<TFlow<A>>.mergeLeft(): TFlow<A> = + TFlowInit(constInit(name = null, mergeNodesLeft { map { it.init.connect(evalScope = this) } })) + +/** + * Creates a new [TFlow] that emits events from all given [TFlow]s. All simultaneous emissions are + * collected into the emitted [List], preserving the input ordering. + * + * @see mergeWith + */ +@ExperimentalFrpApi fun <A> Sequence<TFlow<A>>.merge(): TFlow<List<A>> = asIterable().merge() + +/** + * Creates a new [TFlow] that emits events from all given [TFlow]s. All simultaneous emissions are + * collected into the emitted [Map], and are given the same key of the associated [TFlow] in the + * input [Map]. + * + * @see mergeWith + */ +@ExperimentalFrpApi +fun <K, A> Map<K, TFlow<A>>.merge(): TFlow<Map<K, A>> = + asSequence().map { (k, flowA) -> flowA.map { a -> k to a } }.toList().merge().map { it.toMap() } + +/** + * Returns a [GroupedTFlow] that can be used to efficiently split a single [TFlow] into multiple + * downstream [TFlow]s. + * + * The input [TFlow] emits [Map] instances that specify which downstream [TFlow] the associated + * value will be emitted from. These downstream [TFlow]s can be obtained via + * [GroupedTFlow.eventsForKey]. + * + * An example: + * ``` + * val sFoo: TFlow<Map<String, Foo>> = ... + * val fooById: GroupedTFlow<String, Foo> = sFoo.groupByKey() + * val fooBar: TFlow<Foo> = fooById["bar"] + * ``` + * + * This is semantically equivalent to `val fooBar = sFoo.mapNotNull { map -> map["bar"] }` but is + * significantly more efficient; specifically, using [mapNotNull] in this way incurs a `O(n)` + * performance hit, where `n` is the number of different [mapNotNull] operations used to filter on a + * specific key's presence in the emitted [Map]. [groupByKey] internally uses a [HashMap] to lookup + * the appropriate downstream [TFlow], and so operates in `O(1)`. + * + * Note that the result [GroupedTFlow] should be cached and re-used to gain the performance benefit. + * + * @see selector + */ +@ExperimentalFrpApi +fun <K, A> TFlow<Map<K, A>>.groupByKey(numKeys: Int? = null): GroupedTFlow<K, A> = + GroupedTFlow(DemuxImpl({ init.connect(this) }, numKeys)) + +/** + * Shorthand for `map { mapOf(extractKey(it) to it) }.groupByKey()` + * + * @see groupByKey + */ +@ExperimentalFrpApi +fun <K, A> TFlow<A>.groupBy( + numKeys: Int? = null, + extractKey: suspend FrpTransactionScope.(A) -> K, +): GroupedTFlow<K, A> = map { mapOf(extractKey(it) to it) }.groupByKey(numKeys) + +/** + * Returns two new [TFlow]s that contain elements from this [TFlow] that satisfy or don't satisfy + * [predicate]. + * + * Using this is equivalent to `upstream.filter(predicate) to upstream.filter { !predicate(it) }` + * but is more efficient; specifically, [partition] will only invoke [predicate] once per element. + */ +@ExperimentalFrpApi +fun <A> TFlow<A>.partition( + predicate: suspend FrpTransactionScope.(A) -> Boolean +): Pair<TFlow<A>, TFlow<A>> { + val grouped: GroupedTFlow<Boolean, A> = groupBy(numKeys = 2, extractKey = predicate) + return Pair(grouped.eventsForKey(true), grouped.eventsForKey(false)) +} + +/** + * Returns two new [TFlow]s that contain elements from this [TFlow]; [Pair.first] will contain + * [Left] values, and [Pair.second] will contain [Right] values. + * + * Using this is equivalent to using [filterIsInstance] in conjunction with [map] twice, once for + * [Left]s and once for [Right]s, but is slightly more efficient; specifically, the + * [filterIsInstance] check is only performed once per element. + */ +@ExperimentalFrpApi +fun <A, B> TFlow<Either<A, B>>.partitionEither(): Pair<TFlow<A>, TFlow<B>> { + val (left, right) = partition { it is Left } + return Pair(left.mapCheap { (it as Left).value }, right.mapCheap { (it as Right).value }) +} + +/** + * A mapping from keys of type [K] to [TFlow]s emitting values of type [A]. + * + * @see groupByKey + */ +@ExperimentalFrpApi +class GroupedTFlow<in K, out A> internal constructor(internal val impl: DemuxImpl<K, A>) { + /** + * Returns a [TFlow] that emits values of type [A] that correspond to the given [key]. + * + * @see groupByKey + */ + @ExperimentalFrpApi + fun eventsForKey(key: K): TFlow<A> = TFlowInit(constInit(name = null, impl.eventsForKey(key))) + + /** + * Returns a [TFlow] that emits values of type [A] that correspond to the given [key]. + * + * @see groupByKey + */ + @ExperimentalFrpApi operator fun get(key: K): TFlow<A> = eventsForKey(key) +} + +/** + * Returns a [TFlow] that switches to the [TFlow] contained within this [TState] whenever it + * changes. + * + * This switch does take effect until the *next* transaction after [TState] changes. For a switch + * that takes effect immediately, see [switchPromptly]. + */ +@ExperimentalFrpApi +fun <A> TState<TFlow<A>>.switch(): TFlow<A> { + return TFlowInit( + constInit( + name = null, + switchDeferredImplSingle( + getStorage = { + init.connect(this).getCurrentWithEpoch(this).first.init.connect(this) + }, + getPatches = { + mapImpl({ init.connect(this).changes }) { newFlow -> + newFlow.init.connect(this) + } + }, + ), + ) + ) +} + +/** + * Returns a [TFlow] that switches to the [TFlow] contained within this [TState] whenever it + * changes. + * + * This switch takes effect immediately within the same transaction that [TState] changes. In + * general, you should prefer [switch] over this method. It is both safer and more performant. + */ +// TODO: parameter to handle coincidental emission from both old and new +@ExperimentalFrpApi +fun <A> TState<TFlow<A>>.switchPromptly(): TFlow<A> { + val switchNode = + switchPromptImpl( + getStorage = { + mapOf(Unit to init.connect(this).getCurrentWithEpoch(this).first.init.connect(this)) + }, + getPatches = { + val patches = init.connect(this).changes + mapImpl({ patches }) { newFlow -> mapOf(Unit to just(newFlow.init.connect(this))) } + }, + ) + return TFlowInit(constInit(name = null, mapImpl({ switchNode }) { it.getValue(Unit) })) +} + +/** + * A mutable [TFlow] that provides the ability to [emit] values to the flow, handling backpressure + * by coalescing all emissions into batches. + * + * @see FrpNetwork.coalescingMutableTFlow + */ +@ExperimentalFrpApi +class CoalescingMutableTFlow<In, Out> +internal constructor( + internal val coalesce: (old: Out, new: In) -> Out, + internal val network: Network, + private val getInitialValue: () -> Out, + internal val impl: InputNode<Out> = InputNode(), +) : TFlow<Out>() { + internal val name: String? = null + internal val storage = AtomicReference(false to getInitialValue()) + + override fun toString(): String = "${this::class.simpleName}@$hashString" + + /** + * Inserts [value] into the current batch, enqueueing it for emission from this [TFlow] if not + * already pending. + * + * Backpressure occurs when [emit] is called while the FRP network is currently in a + * transaction; if called multiple times, then emissions will be coalesced into a single batch + * that is then processed when the network is ready. + */ + @ExperimentalFrpApi + fun emit(value: In) { + val (scheduled, _) = storage.getAndUpdate { (_, old) -> true to coalesce(old, value) } + if (!scheduled) { + @Suppress("DeferredResultUnused") + network.transaction { + impl.visit(this, storage.getAndSet(false to getInitialValue()).second) + } + } + } +} + +/** + * A mutable [TFlow] that provides the ability to [emit] values to the flow, handling backpressure + * by suspending the emitter. + * + * @see FrpNetwork.coalescingMutableTFlow + */ +@ExperimentalFrpApi +class MutableTFlow<T> +internal constructor(internal val network: Network, internal val impl: InputNode<T> = InputNode()) : + TFlow<T>() { + internal val name: String? = null + + private val storage = AtomicReference<Job?>(null) + + override fun toString(): String = "${this::class.simpleName}@$hashString" + + /** + * Emits a [value] to this [TFlow], suspending the caller until the FRP transaction containing + * the emission has completed. + */ + @ExperimentalFrpApi + suspend fun emit(value: T) { + coroutineScope { + val newEmit = + async(start = CoroutineStart.LAZY) { + network.transaction { impl.visit(this, value) }.await() + } + val jobOrNull = storage.getAndSet(newEmit) + if (jobOrNull?.isActive != true) { + newEmit.await() + } else { + jobOrNull.join() + } + } + } + + // internal suspend fun emitInCurrentTransaction(value: T, evalScope: EvalScope) { + // if (storage.getAndSet(just(value)) is None) { + // impl.visit(evalScope) + // } + // } +} + +private data object EmptyFlow : TFlow<Nothing>() + +internal class TFlowInit<out A>(val init: Init<TFlowImpl<A>>) : TFlow<A>() { + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +internal val <A> TFlow<A>.init: Init<TFlowImpl<A>> + get() = + when (this) { + is EmptyFlow -> constInit("EmptyFlow", neverImpl) + is TFlowInit -> init + is TFlowLoop -> init + is CoalescingMutableTFlow<*, A> -> constInit(name, impl.activated()) + is MutableTFlow -> constInit(name, impl.activated()) + } + +private inline fun <A> deferInline(crossinline block: suspend InitScope.() -> TFlow<A>): TFlow<A> = + TFlowInit(init(name = null) { block().init.connect(evalScope = this) }) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TState.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TState.kt new file mode 100644 index 000000000000..a5ec503e5c8d --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/TState.kt @@ -0,0 +1,492 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.internal.DerivedMapCheap +import com.android.systemui.experimental.frp.internal.Init +import com.android.systemui.experimental.frp.internal.InitScope +import com.android.systemui.experimental.frp.internal.Network +import com.android.systemui.experimental.frp.internal.NoScope +import com.android.systemui.experimental.frp.internal.Schedulable +import com.android.systemui.experimental.frp.internal.TFlowImpl +import com.android.systemui.experimental.frp.internal.TStateImpl +import com.android.systemui.experimental.frp.internal.TStateSource +import com.android.systemui.experimental.frp.internal.activated +import com.android.systemui.experimental.frp.internal.cached +import com.android.systemui.experimental.frp.internal.constInit +import com.android.systemui.experimental.frp.internal.constS +import com.android.systemui.experimental.frp.internal.filterNode +import com.android.systemui.experimental.frp.internal.flatMap +import com.android.systemui.experimental.frp.internal.init +import com.android.systemui.experimental.frp.internal.map +import com.android.systemui.experimental.frp.internal.mapCheap +import com.android.systemui.experimental.frp.internal.mapImpl +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.internal.zipStates +import kotlin.reflect.KProperty +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** + * A time-varying value with discrete changes. Essentially, a combination of a [Transactional] that + * holds a value, and a [TFlow] that emits when the value changes. + */ +@ExperimentalFrpApi sealed class TState<out A> + +/** A [TState] that never changes. */ +@ExperimentalFrpApi +fun <A> tStateOf(value: A): TState<A> { + val operatorName = "tStateOf" + val name = "$operatorName($value)" + return TStateInit(constInit(name, constS(name, operatorName, value))) +} + +/** TODO */ +@ExperimentalFrpApi fun <A> Lazy<TState<A>>.defer(): TState<A> = deferInline { value } + +/** TODO */ +@ExperimentalFrpApi +fun <A> FrpDeferredValue<TState<A>>.defer(): TState<A> = deferInline { unwrapped.await() } + +/** TODO */ +@ExperimentalFrpApi +fun <A> deferTState(block: suspend FrpScope.() -> TState<A>): TState<A> = deferInline { + NoScope.runInFrpScope(block) +} + +/** + * Returns a [TState] containing the results of applying [transform] to the value held by the + * original [TState]. + */ +@ExperimentalFrpApi +fun <A, B> TState<A>.map(transform: suspend FrpScope.(A) -> B): TState<B> { + val operatorName = "map" + val name = operatorName + return TStateInit( + init(name) { + init.connect(evalScope = this).map(name, operatorName) { + NoScope.runInFrpScope { transform(it) } + } + } + ) +} + +/** + * Returns a [TState] that transforms the value held inside this [TState] by applying it to the + * [transform]. + * + * Note that unlike [map], the result is not cached. This means that not only should [transform] be + * fast and pure, it should be *monomorphic* (1-to-1). Failure to do this means that [stateChanges] + * for the returned [TState] will operate unexpectedly, emitting at rates that do not reflect an + * observable change to the returned [TState]. + */ +@ExperimentalFrpApi +fun <A, B> TState<A>.mapCheapUnsafe(transform: suspend FrpScope.(A) -> B): TState<B> { + val operatorName = "map" + val name = operatorName + return TStateInit( + init(name) { + init.connect(evalScope = this).mapCheap(name, operatorName) { + NoScope.runInFrpScope { transform(it) } + } + } + ) +} + +/** + * Returns a [TState] by combining the values held inside the given [TState]s by applying them to + * the given function [transform]. + */ +@ExperimentalFrpApi +fun <A, B, C> TState<A>.combineWith( + other: TState<B>, + transform: suspend FrpScope.(A, B) -> C, +): TState<C> = combine(this, other, transform) + +/** + * Splits a [TState] of pairs into a pair of [TFlows][TState], where each returned [TState] holds + * hald of the original. + * + * Shorthand for: + * ```kotlin + * val lefts = map { it.first } + * val rights = map { it.second } + * return Pair(lefts, rights) + * ``` + */ +@ExperimentalFrpApi +fun <A, B> TState<Pair<A, B>>.unzip(): Pair<TState<A>, TState<B>> { + val left = map { it.first } + val right = map { it.second } + return left to right +} + +/** + * Returns a [TState] by combining the values held inside the given [TStates][TState] into a [List]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A> Iterable<TState<A>>.combine(): TState<List<A>> { + val operatorName = "combine" + val name = operatorName + return TStateInit( + init(name) { + zipStates(name, operatorName, states = map { it.init.connect(evalScope = this) }) + } + ) +} + +/** + * Returns a [TState] by combining the values held inside the given [TStates][TState] into a [Map]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <K : Any, A> Map<K, TState<A>>.combine(): TState<Map<K, A>> { + val operatorName = "combine" + val name = operatorName + return TStateInit( + init(name) { + zipStates( + name, + operatorName, + states = mapValues { it.value.init.connect(evalScope = this) }, + ) + } + ) +} + +/** + * Returns a [TState] whose value is generated with [transform] by combining the current values of + * each given [TState]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A, B> Iterable<TState<A>>.combine(transform: suspend FrpScope.(List<A>) -> B): TState<B> = + combine().map(transform) + +/** + * Returns a [TState] by combining the values held inside the given [TState]s into a [List]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A> combine(vararg states: TState<A>): TState<List<A>> = states.asIterable().combine() + +/** + * Returns a [TState] whose value is generated with [transform] by combining the current values of + * each given [TState]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A, B> combine( + vararg states: TState<A>, + transform: suspend FrpScope.(List<A>) -> B, +): TState<B> = states.asIterable().combine(transform) + +/** + * Returns a [TState] whose value is generated with [transform] by combining the current values of + * each given [TState]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A, B, Z> combine( + stateA: TState<A>, + stateB: TState<B>, + transform: suspend FrpScope.(A, B) -> Z, +): TState<Z> { + val operatorName = "combine" + val name = operatorName + return TStateInit( + init(name) { + coroutineScope { + val dl1: Deferred<TStateImpl<A>> = async { + stateA.init.connect(evalScope = this@init) + } + val dl2: Deferred<TStateImpl<B>> = async { + stateB.init.connect(evalScope = this@init) + } + zipStates(name, operatorName, dl1.await(), dl2.await()) { a, b -> + NoScope.runInFrpScope { transform(a, b) } + } + } + } + ) +} + +/** + * Returns a [TState] whose value is generated with [transform] by combining the current values of + * each given [TState]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A, B, C, Z> combine( + stateA: TState<A>, + stateB: TState<B>, + stateC: TState<C>, + transform: suspend FrpScope.(A, B, C) -> Z, +): TState<Z> { + val operatorName = "combine" + val name = operatorName + return TStateInit( + init(name) { + coroutineScope { + val dl1: Deferred<TStateImpl<A>> = async { + stateA.init.connect(evalScope = this@init) + } + val dl2: Deferred<TStateImpl<B>> = async { + stateB.init.connect(evalScope = this@init) + } + val dl3: Deferred<TStateImpl<C>> = async { + stateC.init.connect(evalScope = this@init) + } + zipStates(name, operatorName, dl1.await(), dl2.await(), dl3.await()) { a, b, c -> + NoScope.runInFrpScope { transform(a, b, c) } + } + } + } + ) +} + +/** + * Returns a [TState] whose value is generated with [transform] by combining the current values of + * each given [TState]. + * + * @see TState.combineWith + */ +@ExperimentalFrpApi +fun <A, B, C, D, Z> combine( + stateA: TState<A>, + stateB: TState<B>, + stateC: TState<C>, + stateD: TState<D>, + transform: suspend FrpScope.(A, B, C, D) -> Z, +): TState<Z> { + val operatorName = "combine" + val name = operatorName + return TStateInit( + init(name) { + coroutineScope { + val dl1: Deferred<TStateImpl<A>> = async { + stateA.init.connect(evalScope = this@init) + } + val dl2: Deferred<TStateImpl<B>> = async { + stateB.init.connect(evalScope = this@init) + } + val dl3: Deferred<TStateImpl<C>> = async { + stateC.init.connect(evalScope = this@init) + } + val dl4: Deferred<TStateImpl<D>> = async { + stateD.init.connect(evalScope = this@init) + } + zipStates(name, operatorName, dl1.await(), dl2.await(), dl3.await(), dl4.await()) { + a, + b, + c, + d -> + NoScope.runInFrpScope { transform(a, b, c, d) } + } + } + } + ) +} + +/** Returns a [TState] by applying [transform] to the value held by the original [TState]. */ +@ExperimentalFrpApi +fun <A, B> TState<A>.flatMap(transform: suspend FrpScope.(A) -> TState<B>): TState<B> { + val operatorName = "flatMap" + val name = operatorName + return TStateInit( + init(name) { + init.connect(this).flatMap(name, operatorName) { a -> + NoScope.runInFrpScope { transform(a) }.init.connect(this) + } + } + ) +} + +/** Shorthand for `flatMap { it }` */ +@ExperimentalFrpApi fun <A> TState<TState<A>>.flatten() = flatMap { it } + +/** + * Returns a [TStateSelector] that can be used to efficiently check if the input [TState] is + * currently holding a specific value. + * + * An example: + * ``` + * val lInt: TState<Int> = ... + * val intSelector: TStateSelector<Int> = lInt.selector() + * // Tracks if lInt is holding 1 + * val isOne: TState<Boolean> = intSelector.whenSelected(1) + * ``` + * + * This is semantically equivalent to `val isOne = lInt.map { i -> i == 1 }`, but is significantly + * more efficient; specifically, using [TState.map] in this way incurs a `O(n)` performance hit, + * where `n` is the number of different [TState.map] operations used to track a specific value. + * [selector] internally uses a [HashMap] to lookup the appropriate downstream [TState] to update, + * and so operates in `O(1)`. + * + * Note that the result [TStateSelector] should be cached and re-used to gain the performance + * benefit. + * + * @see groupByKey + */ +@ExperimentalFrpApi +fun <A> TState<A>.selector(numDistinctValues: Int? = null): TStateSelector<A> = + TStateSelector( + this, + stateChanges + .map { new -> mapOf(new to true, sampleDeferred().get() to false) } + .groupByKey(numDistinctValues), + ) + +/** + * Tracks the currently selected value of type [A] from an upstream [TState]. + * + * @see selector + */ +@ExperimentalFrpApi +class TStateSelector<A> +internal constructor( + private val upstream: TState<A>, + private val groupedChanges: GroupedTFlow<A, Boolean>, +) { + /** + * Returns a [TState] that tracks whether the upstream [TState] is currently holding the given + * [value]. + * + * @see selector + */ + @ExperimentalFrpApi + fun whenSelected(value: A): TState<Boolean> { + val operatorName = "TStateSelector#whenSelected" + val name = "$operatorName[$value]" + return TStateInit( + init(name) { + DerivedMapCheap( + name, + operatorName, + upstream = upstream.init.connect(evalScope = this), + changes = groupedChanges.impl.eventsForKey(value), + ) { + it == value + } + } + ) + } + + @ExperimentalFrpApi operator fun get(value: A): TState<Boolean> = whenSelected(value) +} + +/** TODO */ +@ExperimentalFrpApi +class MutableTState<T> +internal constructor(internal val network: Network, initialValue: Deferred<T>) : TState<T>() { + + private val input: CoalescingMutableTFlow<Deferred<T>, Deferred<T>?> = + CoalescingMutableTFlow( + coalesce = { _, new -> new }, + network = network, + getInitialValue = { null }, + ) + + internal val tState = run { + val changes = input.impl + val name = null + val operatorName = "MutableTState" + lateinit var state: TStateSource<T> + val calm: TFlowImpl<T> = + filterNode({ mapImpl(upstream = { changes.activated() }) { it!!.await() } }) { new -> + new != state.getCurrentWithEpoch(evalScope = this).first + } + .cached() + state = TStateSource(name, operatorName, initialValue, calm) + @Suppress("DeferredResultUnused") + network.transaction { + calm.activate(evalScope = this, downstream = Schedulable.S(state))?.let { + (connection, needsEval) -> + state.upstreamConnection = connection + if (needsEval) { + schedule(state) + } + } + } + TStateInit(constInit(name, state)) + } + + /** TODO */ + @ExperimentalFrpApi fun setValue(value: T) = input.emit(CompletableDeferred(value)) + + @ExperimentalFrpApi + fun setValueDeferred(value: FrpDeferredValue<T>) = input.emit(value.unwrapped) +} + +/** A forward-reference to a [TState], allowing for recursive definitions. */ +@ExperimentalFrpApi +class TStateLoop<A> : TState<A>() { + + private val name: String? = null + + private val deferred = CompletableDeferred<TState<A>>() + + internal val init: Init<TStateImpl<A>> = + init(name) { deferred.await().init.connect(evalScope = this) } + + /** The [TState] this [TStateLoop] will forward to. */ + @ExperimentalFrpApi + var loopback: TState<A>? = null + set(value) { + value?.let { + check(deferred.complete(value)) { "TStateLoop.loopback has already been set." } + field = value + } + } + + @ExperimentalFrpApi + operator fun getValue(thisRef: Any?, property: KProperty<*>): TState<A> = this + + @ExperimentalFrpApi + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: TState<A>) { + loopback = value + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +internal class TStateInit<A> internal constructor(internal val init: Init<TStateImpl<A>>) : + TState<A>() { + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +internal val <A> TState<A>.init: Init<TStateImpl<A>> + get() = + when (this) { + is TStateInit -> init + is TStateLoop -> init + is MutableTState -> tState.init + } + +private inline fun <A> deferInline( + crossinline block: suspend InitScope.() -> TState<A> +): TState<A> = TStateInit(init(name = null) { block().init.connect(evalScope = this) }) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Transactional.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Transactional.kt new file mode 100644 index 000000000000..0e7b420afd51 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/Transactional.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.internal.InitScope +import com.android.systemui.experimental.frp.internal.NoScope +import com.android.systemui.experimental.frp.internal.TransactionalImpl +import com.android.systemui.experimental.frp.internal.init +import com.android.systemui.experimental.frp.internal.transactionalImpl +import com.android.systemui.experimental.frp.internal.util.hashString +import kotlinx.coroutines.CompletableDeferred + +/** + * A time-varying value. A [Transactional] encapsulates the idea of some continuous state; each time + * it is "sampled", a new result may be produced. + * + * Because FRP operates over an "idealized" model of Time that can be passed around as a data type, + * [Transactional]s are guaranteed to produce the same result if queried multiple times at the same + * (conceptual) time, in order to preserve _referential transparency_. + */ +@ExperimentalFrpApi +class Transactional<out A> internal constructor(internal val impl: TState<TransactionalImpl<A>>) { + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +/** A constant [Transactional] that produces [value] whenever it is sampled. */ +@ExperimentalFrpApi +fun <A> transactionalOf(value: A): Transactional<A> = + Transactional(tStateOf(TransactionalImpl.Const(CompletableDeferred(value)))) + +/** TODO */ +@ExperimentalFrpApi +fun <A> FrpDeferredValue<Transactional<A>>.defer(): Transactional<A> = deferInline { + unwrapped.await() +} + +/** TODO */ +@ExperimentalFrpApi fun <A> Lazy<Transactional<A>>.defer(): Transactional<A> = deferInline { value } + +/** TODO */ +@ExperimentalFrpApi +fun <A> deferTransactional(block: suspend FrpScope.() -> Transactional<A>): Transactional<A> = + deferInline { + NoScope.runInFrpScope(block) + } + +private inline fun <A> deferInline( + crossinline block: suspend InitScope.() -> Transactional<A> +): Transactional<A> = + Transactional(TStateInit(init(name = null) { block().impl.init.connect(evalScope = this) })) + +/** + * Returns a [Transactional]. The passed [block] will be evaluated on demand at most once per + * transaction; any subsequent sampling within the same transaction will receive a cached value. + */ +@ExperimentalFrpApi +fun <A> transactionally(block: suspend FrpTransactionScope.() -> A): Transactional<A> = + Transactional(tStateOf(transactionalImpl { runInTransactionScope(block) })) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/debug/Debug.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/debug/Debug.kt new file mode 100644 index 000000000000..806234184d81 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/debug/Debug.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.debug + +import com.android.systemui.experimental.frp.MutableTState +import com.android.systemui.experimental.frp.TState +import com.android.systemui.experimental.frp.TStateInit +import com.android.systemui.experimental.frp.TStateLoop +import com.android.systemui.experimental.frp.internal.DerivedFlatten +import com.android.systemui.experimental.frp.internal.DerivedMap +import com.android.systemui.experimental.frp.internal.DerivedMapCheap +import com.android.systemui.experimental.frp.internal.DerivedZipped +import com.android.systemui.experimental.frp.internal.Init +import com.android.systemui.experimental.frp.internal.TStateDerived +import com.android.systemui.experimental.frp.internal.TStateImpl +import com.android.systemui.experimental.frp.internal.TStateSource +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.none +import com.android.systemui.experimental.frp.util.orElseGet + +// object IdGen { +// private val counter = AtomicLong() +// fun getId() = counter.getAndIncrement() +// } + +typealias StateGraph = Graph<ActivationInfo> + +sealed class StateInfo( + val name: String, + val value: Maybe<Any?>, + val operator: String, + val epoch: Long?, +) + +class Source(name: String, value: Maybe<Any?>, operator: String, epoch: Long) : + StateInfo(name, value, operator, epoch) + +class Derived( + name: String, + val type: DerivedStateType, + value: Maybe<Any?>, + operator: String, + epoch: Long?, +) : StateInfo(name, value, operator, epoch) + +sealed interface DerivedStateType + +data object Flatten : DerivedStateType + +data class Mapped(val cheap: Boolean) : DerivedStateType + +data object Combine : DerivedStateType + +sealed class InitInfo(val name: String) + +class Uninitialized(name: String) : InitInfo(name) + +class Initialized(val state: StateInfo) : InitInfo(state.name) + +sealed interface ActivationInfo + +class Inactive(val name: String) : ActivationInfo + +class Active(val nodeInfo: StateInfo) : ActivationInfo + +class Dead(val name: String) : ActivationInfo + +data class Edge(val upstream: Any, val downstream: Any, val tag: Any? = null) + +data class Graph<T>(val nodes: Map<Any, T>, val edges: List<Edge>) + +internal fun TState<*>.dump(infoMap: MutableMap<Any, InitInfo>, edges: MutableList<Edge>) { + val init: Init<TStateImpl<Any?>> = + when (this) { + is TStateInit -> init + is TStateLoop -> init + is MutableTState -> tState.init + } + when (val stateMaybe = init.getUnsafe()) { + None -> { + infoMap[this] = Uninitialized(init.name ?: init.toString()) + } + is Just -> { + stateMaybe.value.dump(infoMap, edges) + } + } +} + +internal fun TStateImpl<*>.dump(infoById: MutableMap<Any, InitInfo>, edges: MutableList<Edge>) { + val state = this + if (state in infoById) return + val stateInfo = + when (state) { + is TStateDerived -> { + val type = + when (state) { + is DerivedFlatten -> { + state.upstream.dump(infoById, edges) + edges.add( + Edge(upstream = state.upstream, downstream = state, tag = "outer") + ) + state.upstream + .getUnsafe() + .orElseGet { null } + ?.let { + edges.add( + Edge(upstream = it, downstream = state, tag = "inner") + ) + it.dump(infoById, edges) + } + Flatten + } + is DerivedMap<*, *> -> { + state.upstream.dump(infoById, edges) + edges.add(Edge(upstream = state.upstream, downstream = state)) + Mapped(cheap = false) + } + is DerivedZipped<*, *> -> { + state.upstream.forEach { (key, upstream) -> + edges.add( + Edge(upstream = upstream, downstream = state, tag = "key=$key") + ) + upstream.dump(infoById, edges) + } + Combine + } + } + Derived( + state.name ?: state.operatorName, + type, + state.getCachedUnsafe(), + state.operatorName, + state.invalidatedEpoch, + ) + } + is TStateSource -> + Source( + state.name ?: state.operatorName, + state.getStorageUnsafe(), + state.operatorName, + state.writeEpoch, + ) + is DerivedMapCheap<*, *> -> { + state.upstream.dump(infoById, edges) + edges.add(Edge(upstream = state.upstream, downstream = state)) + val type = Mapped(cheap = true) + Derived( + state.name ?: state.operatorName, + type, + state.getUnsafe(), + state.operatorName, + null, + ) + } + } + infoById[state] = Initialized(stateInfo) +} + +private fun <A> TStateImpl<A>.getUnsafe(): Maybe<A> = + when (this) { + is TStateDerived -> getCachedUnsafe() + is TStateSource -> getStorageUnsafe() + is DerivedMapCheap<*, *> -> none + } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/BuildScopeImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/BuildScopeImpl.kt new file mode 100644 index 000000000000..127abd857fb3 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/BuildScopeImpl.kt @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.CoalescingMutableTFlow +import com.android.systemui.experimental.frp.FrpBuildScope +import com.android.systemui.experimental.frp.FrpCoalescingProducerScope +import com.android.systemui.experimental.frp.FrpDeferredValue +import com.android.systemui.experimental.frp.FrpEffectScope +import com.android.systemui.experimental.frp.FrpNetwork +import com.android.systemui.experimental.frp.FrpProducerScope +import com.android.systemui.experimental.frp.FrpSpec +import com.android.systemui.experimental.frp.FrpStateScope +import com.android.systemui.experimental.frp.FrpTransactionScope +import com.android.systemui.experimental.frp.GroupedTFlow +import com.android.systemui.experimental.frp.LocalFrpNetwork +import com.android.systemui.experimental.frp.MutableTFlow +import com.android.systemui.experimental.frp.TFlow +import com.android.systemui.experimental.frp.TFlowInit +import com.android.systemui.experimental.frp.groupByKey +import com.android.systemui.experimental.frp.init +import com.android.systemui.experimental.frp.internal.util.childScope +import com.android.systemui.experimental.frp.internal.util.launchOnCancel +import com.android.systemui.experimental.frp.internal.util.mapValuesParallel +import com.android.systemui.experimental.frp.launchEffect +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.map +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.completeWith +import kotlinx.coroutines.job + +internal class BuildScopeImpl(val stateScope: StateScopeImpl, val coroutineScope: CoroutineScope) : + BuildScope, StateScope by stateScope { + + private val job: Job + get() = coroutineScope.coroutineContext.job + + override val frpScope: FrpBuildScope = FrpBuildScopeImpl() + + override suspend fun <R> runInBuildScope(block: suspend FrpBuildScope.() -> R): R { + val complete = CompletableDeferred<R>(parent = coroutineContext.job) + block.startCoroutine( + frpScope, + object : Continuation<R> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<R>) { + complete.completeWith(result) + } + }, + ) + return complete.await() + } + + private fun <A, T : TFlow<A>, S> buildTFlow( + constructFlow: (InputNode<A>) -> Pair<T, S>, + builder: suspend S.() -> Unit, + ): TFlow<A> { + var job: Job? = null + val stopEmitter = newStopEmitter() + val handle = this.job.invokeOnCompletion { stopEmitter.emit(Unit) } + // Create a child scope that will be kept alive beyond the end of this transaction. + val childScope = coroutineScope.childScope() + lateinit var emitter: Pair<T, S> + val inputNode = + InputNode<A>( + activate = { + check(job == null) { "already activated" } + job = + reenterBuildScope(this@BuildScopeImpl, childScope).runInBuildScope { + launchEffect { + builder(emitter.second) + handle.dispose() + stopEmitter.emit(Unit) + } + } + }, + deactivate = { + checkNotNull(job) { "already deactivated" }.cancel() + job = null + }, + ) + emitter = constructFlow(inputNode) + return with(frpScope) { emitter.first.takeUntil(stopEmitter) } + } + + private fun <T> tFlowInternal(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T> = + buildTFlow( + constructFlow = { inputNode -> + val flow = MutableTFlow(network, inputNode) + flow to + object : FrpProducerScope<T> { + override suspend fun emit(value: T) { + flow.emit(value) + } + } + }, + builder = builder, + ) + + private fun <In, Out> coalescingTFlowInternal( + getInitialValue: () -> Out, + coalesce: (old: Out, new: In) -> Out, + builder: suspend FrpCoalescingProducerScope<In>.() -> Unit, + ): TFlow<Out> = + buildTFlow( + constructFlow = { inputNode -> + val flow = CoalescingMutableTFlow(coalesce, network, getInitialValue, inputNode) + flow to + object : FrpCoalescingProducerScope<In> { + override fun emit(value: In) { + flow.emit(value) + } + } + }, + builder = builder, + ) + + private fun <A> asyncScopeInternal(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job> { + val childScope = mutableChildBuildScope() + return FrpDeferredValue(deferAsync { childScope.runInBuildScope(block) }) to childScope.job + } + + private fun <R> deferredInternal(block: suspend FrpBuildScope.() -> R): FrpDeferredValue<R> = + FrpDeferredValue(deferAsync { runInBuildScope(block) }) + + private fun deferredActionInternal(block: suspend FrpBuildScope.() -> Unit) { + deferAction { runInBuildScope(block) } + } + + private fun <A> TFlow<A>.observeEffectInternal( + context: CoroutineContext, + block: suspend FrpEffectScope.(A) -> Unit, + ): Job { + val subRef = AtomicReference<Maybe<Output<A>>>(null) + val childScope = coroutineScope.childScope() + // When our scope is cancelled, deactivate this observer. + childScope.launchOnCancel(CoroutineName("TFlow.observeEffect")) { + subRef.getAndSet(None)?.let { output -> + if (output is Just) { + @Suppress("DeferredResultUnused") + network.transaction { scheduleDeactivation(output.value) } + } + } + } + // Defer so that we don't suspend the caller + deferAction { + val outputNode = + Output<A>( + context = context, + onDeath = { subRef.getAndSet(None)?.let { childScope.cancel() } }, + onEmit = { output -> + if (subRef.get() is Just) { + // Not cancelled, safe to emit + val coroutine: suspend FrpEffectScope.() -> Unit = { block(output) } + val complete = CompletableDeferred<Unit>(parent = coroutineContext.job) + coroutine.startCoroutine( + object : FrpEffectScope, FrpTransactionScope by frpScope { + override val frpCoroutineScope: CoroutineScope = childScope + override val frpNetwork: FrpNetwork = + LocalFrpNetwork(network, childScope, endSignal) + }, + completion = + object : Continuation<Unit> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<Unit>) { + complete.completeWith(result) + } + }, + ) + complete.await() + } + }, + ) + with(frpScope) { this@observeEffectInternal.takeUntil(endSignal) } + .init + .connect(evalScope = stateScope.evalScope) + .activate(evalScope = stateScope.evalScope, outputNode.schedulable) + ?.let { (conn, needsEval) -> + outputNode.upstream = conn + if (!subRef.compareAndSet(null, just(outputNode))) { + // Job's already been cancelled, schedule deactivation + scheduleDeactivation(outputNode) + } else if (needsEval) { + outputNode.schedule(evalScope = stateScope.evalScope) + } + } ?: childScope.cancel() + } + return childScope.coroutineContext.job + } + + private fun <A, B> TFlow<A>.mapBuildInternal( + transform: suspend FrpBuildScope.(A) -> B + ): TFlow<B> { + val childScope = coroutineScope.childScope() + return TFlowInit( + constInit( + "mapBuild", + mapImpl({ init.connect(evalScope = this) }) { spec -> + reenterBuildScope(outerScope = this@BuildScopeImpl, childScope) + .runInBuildScope { + val (result, _) = asyncScope { transform(spec) } + result.get() + } + } + .cached(), + ) + ) + } + + private fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestForKeyInternal( + init: FrpDeferredValue<Map<K, FrpSpec<B>>>, + numKeys: Int?, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> { + val eventsByKey: GroupedTFlow<K, Maybe<FrpSpec<A>>> = groupByKey(numKeys) + val initOut: Deferred<Map<K, B>> = deferAsync { + init.unwrapped.await().mapValuesParallel { (k, spec) -> + val newEnd = with(frpScope) { eventsByKey[k].skipNext() } + val newScope = childBuildScope(newEnd) + newScope.runInBuildScope(spec) + } + } + val childScope = coroutineScope.childScope() + val changesNode: TFlowImpl<Map<K, Maybe<A>>> = + mapImpl(upstream = { this@applyLatestForKeyInternal.init.connect(evalScope = this) }) { + upstreamMap -> + reenterBuildScope(this@BuildScopeImpl, childScope).run { + upstreamMap.mapValuesParallel { (k: K, ma: Maybe<FrpSpec<A>>) -> + ma.map { spec -> + val newEnd = with(frpScope) { eventsByKey[k].skipNext() } + val newScope = childBuildScope(newEnd) + newScope.runInBuildScope(spec) + } + } + } + } + val changes: TFlow<Map<K, Maybe<A>>> = + TFlowInit(constInit("applyLatestForKey", changesNode.cached())) + // Ensure effects are observed; otherwise init will stay alive longer than expected + changes.observeEffectInternal(EmptyCoroutineContext) {} + return changes to FrpDeferredValue(initOut) + } + + private fun newStopEmitter(): CoalescingMutableTFlow<Unit, Unit> = + CoalescingMutableTFlow( + coalesce = { _, _: Unit -> }, + network = network, + getInitialValue = {}, + ) + + private suspend fun childBuildScope(newEnd: TFlow<Any>): BuildScopeImpl { + val newCoroutineScope: CoroutineScope = coroutineScope.childScope() + return BuildScopeImpl( + stateScope = stateScope.childStateScope(newEnd), + coroutineScope = newCoroutineScope, + ) + .apply { + // Ensure that once this transaction is done, the new child scope enters the + // completing state (kept alive so long as there are child jobs). + scheduleOutput( + OneShot { + // TODO: don't like this cast + (newCoroutineScope.coroutineContext.job as CompletableJob).complete() + } + ) + runInBuildScope { endSignal.nextOnly().observe { newCoroutineScope.cancel() } } + } + } + + private fun mutableChildBuildScope(): BuildScopeImpl { + val stopEmitter = newStopEmitter() + val childScope = coroutineScope.childScope() + childScope.coroutineContext.job.invokeOnCompletion { stopEmitter.emit(Unit) } + // Ensure that once this transaction is done, the new child scope enters the completing + // state (kept alive so long as there are child jobs). + scheduleOutput( + OneShot { + // TODO: don't like this cast + (childScope.coroutineContext.job as CompletableJob).complete() + } + ) + return BuildScopeImpl( + stateScope = StateScopeImpl(evalScope = stateScope.evalScope, endSignal = stopEmitter), + coroutineScope = childScope, + ) + } + + private inner class FrpBuildScopeImpl : FrpBuildScope, FrpStateScope by stateScope.frpScope { + + override fun <T> tFlow(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T> = + tFlowInternal(builder) + + override fun <In, Out> coalescingTFlow( + getInitialValue: () -> Out, + coalesce: (old: Out, new: In) -> Out, + builder: suspend FrpCoalescingProducerScope<In>.() -> Unit, + ): TFlow<Out> = coalescingTFlowInternal(getInitialValue, coalesce, builder) + + override fun <A> asyncScope(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job> = + asyncScopeInternal(block) + + override fun <R> deferredBuildScope( + block: suspend FrpBuildScope.() -> R + ): FrpDeferredValue<R> = deferredInternal(block) + + override fun deferredBuildScopeAction(block: suspend FrpBuildScope.() -> Unit) = + deferredActionInternal(block) + + override fun <A> TFlow<A>.observe( + coroutineContext: CoroutineContext, + block: suspend FrpEffectScope.(A) -> Unit, + ): Job = observeEffectInternal(coroutineContext, block) + + override fun <A, B> TFlow<A>.mapBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B> = + mapBuildInternal(transform) + + override fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey( + initialSpecs: FrpDeferredValue<Map<K, FrpSpec<B>>>, + numKeys: Int?, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> = + applyLatestForKeyInternal(initialSpecs, numKeys) + } +} + +private fun EvalScope.reenterBuildScope( + outerScope: BuildScopeImpl, + coroutineScope: CoroutineScope, +) = + BuildScopeImpl( + stateScope = StateScopeImpl(evalScope = this, endSignal = outerScope.endSignal), + coroutineScope, + ) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/DeferScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/DeferScope.kt new file mode 100644 index 000000000000..f72ba5fdfc6f --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/DeferScope.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.asyncImmediate +import com.android.systemui.experimental.frp.internal.util.launchImmediate +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive + +internal typealias DeferScope = CoroutineScope + +internal inline fun DeferScope.deferAction( + start: CoroutineStart = CoroutineStart.UNDISPATCHED, + crossinline block: suspend () -> Unit, +): Job { + check(isActive) { "Cannot perform deferral, scope already closed." } + return launchImmediate(start, CoroutineName("deferAction")) { block() } +} + +internal inline fun <R> DeferScope.deferAsync( + start: CoroutineStart = CoroutineStart.UNDISPATCHED, + crossinline block: suspend () -> R, +): Deferred<R> { + check(isActive) { "Cannot perform deferral, scope already closed." } + return asyncImmediate(start, CoroutineName("deferAsync")) { block() } +} + +internal suspend inline fun <A> deferScope(noinline block: suspend DeferScope.() -> A): A = + coroutineScope(block) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Demux.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Demux.kt new file mode 100644 index 000000000000..418220f2da4d --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Demux.kt @@ -0,0 +1,349 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE") + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.flatMap +import com.android.systemui.experimental.frp.util.getMaybe +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class DemuxNode<K, A>( + private val branchNodeByKey: ConcurrentHashMap<K, DemuxBranchNode<K, A>>, + val lifecycle: DemuxLifecycle<K, A>, + private val spec: DemuxActivator<K, A>, +) : SchedulableNode { + + val schedulable = Schedulable.N(this) + + inline val mutex + get() = lifecycle.mutex + + lateinit var upstreamConnection: NodeConnection<Map<K, A>> + + fun getAndMaybeAddDownstream(key: K): DemuxBranchNode<K, A> = + branchNodeByKey.getOrPut(key) { DemuxBranchNode(key, this) } + + override suspend fun schedule(evalScope: EvalScope) { + val upstreamResult = upstreamConnection.getPushEvent(evalScope) + if (upstreamResult is Just) { + coroutineScope { + val outerScope = this + mutex.withLock { + coroutineScope { + for ((key, _) in upstreamResult.value) { + launch { + branchNodeByKey[key]?.let { branch -> + outerScope.launch { branch.schedule(evalScope) } + } + } + } + } + } + } + } + } + + override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) { + coroutineScope { + mutex.withLock { + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.adjustDirectUpstream( + coroutineScope = this, + scheduler, + oldDepth, + newDepth, + ) + } + } + } + } + + override suspend fun moveIndirectUpstreamToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + newDirectDepth: Int, + ) { + coroutineScope { + mutex.withLock { + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.moveIndirectUpstreamToDirect( + coroutineScope = this, + scheduler, + oldIndirectDepth, + oldIndirectSet, + newDirectDepth, + ) + } + } + } + } + + override suspend fun adjustIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) { + coroutineScope { + mutex.withLock { + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.adjustIndirectUpstream( + coroutineScope = this, + scheduler, + oldDepth, + newDepth, + removals, + additions, + ) + } + } + } + } + + override suspend fun moveDirectUpstreamToIndirect( + scheduler: Scheduler, + oldDirectDepth: Int, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + coroutineScope { + mutex.withLock { + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.moveDirectUpstreamToIndirect( + coroutineScope = this, + scheduler, + oldDirectDepth, + newIndirectDepth, + newIndirectSet, + ) + } + } + } + } + + override suspend fun removeIndirectUpstream( + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) { + coroutineScope { + mutex.withLock { + lifecycle.lifecycleState = DemuxLifecycleState.Dead + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.removeIndirectUpstream( + coroutineScope = this, + scheduler, + depth, + indirectSet, + ) + } + } + } + } + + override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) { + coroutineScope { + mutex.withLock { + lifecycle.lifecycleState = DemuxLifecycleState.Dead + for ((_, branchNode) in branchNodeByKey) { + branchNode.downstreamSet.removeDirectUpstream( + coroutineScope = this, + scheduler, + depth, + ) + } + } + } + } + + suspend fun removeDownstreamAndDeactivateIfNeeded(key: K) { + val deactivate = + mutex.withLock { + branchNodeByKey.remove(key) + branchNodeByKey.isEmpty() + } + if (deactivate) { + // No need for mutex here; no more concurrent changes to can occur during this phase + lifecycle.lifecycleState = DemuxLifecycleState.Inactive(spec) + upstreamConnection.removeDownstreamAndDeactivateIfNeeded(downstream = schedulable) + } + } +} + +internal class DemuxBranchNode<K, A>(val key: K, private val demuxNode: DemuxNode<K, A>) : + PushNode<A> { + + private val mutex = Mutex() + + val downstreamSet = DownstreamSet() + + override val depthTracker: DepthTracker + get() = demuxNode.upstreamConnection.depthTracker + + override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = + demuxNode.upstreamConnection.hasCurrentValue(transactionStore) + + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> = + demuxNode.upstreamConnection.getPushEvent(evalScope).flatMap { it.getMaybe(key) } + + override suspend fun addDownstream(downstream: Schedulable) { + mutex.withLock { downstreamSet.add(downstream) } + } + + override suspend fun removeDownstream(downstream: Schedulable) { + mutex.withLock { downstreamSet.remove(downstream) } + } + + override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) { + val canDeactivate = + mutex.withLock { + downstreamSet.remove(downstream) + downstreamSet.isEmpty() + } + if (canDeactivate) { + demuxNode.removeDownstreamAndDeactivateIfNeeded(key) + } + } + + override suspend fun deactivateIfNeeded() { + if (mutex.withLock { downstreamSet.isEmpty() }) { + demuxNode.removeDownstreamAndDeactivateIfNeeded(key) + } + } + + override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) { + if (mutex.withLock { downstreamSet.isEmpty() }) { + evalScope.scheduleDeactivation(this) + } + } + + suspend fun schedule(evalScope: EvalScope) { + if (!coroutineScope { mutex.withLock { scheduleAll(downstreamSet, evalScope) } }) { + evalScope.scheduleDeactivation(this) + } + } +} + +internal fun <K, A> DemuxImpl( + upstream: suspend EvalScope.() -> TFlowImpl<Map<K, A>>, + numKeys: Int?, +): DemuxImpl<K, A> = + DemuxImpl( + DemuxLifecycle( + object : DemuxActivator<K, A> { + override suspend fun activate( + evalScope: EvalScope, + lifecycle: DemuxLifecycle<K, A>, + ): Pair<DemuxNode<K, A>, Boolean>? { + val dmux = DemuxNode(ConcurrentHashMap(numKeys ?: 16), lifecycle, this) + return upstream + .invoke(evalScope) + .activate(evalScope, downstream = dmux.schedulable) + ?.let { (conn, needsEval) -> + dmux.apply { upstreamConnection = conn } to needsEval + } + } + } + ) + ) + +internal class DemuxImpl<in K, out A>(private val dmux: DemuxLifecycle<K, A>) { + fun eventsForKey(key: K): TFlowImpl<A> = TFlowCheap { downstream -> + dmux.activate(evalScope = this, key)?.let { (branchNode, needsEval) -> + branchNode.addDownstream(downstream) + val branchNeedsEval = needsEval && branchNode.getPushEvent(evalScope = this) is Just + ActivationResult( + connection = NodeConnection(branchNode, branchNode), + needsEval = branchNeedsEval, + ) + } + } +} + +internal class DemuxLifecycle<K, A>(@Volatile var lifecycleState: DemuxLifecycleState<K, A>) { + val mutex = Mutex() + + override fun toString(): String = "TFlowDmuxState[$hashString][$lifecycleState][$mutex]" + + suspend fun activate(evalScope: EvalScope, key: K): Pair<DemuxBranchNode<K, A>, Boolean>? = + coroutineScope { + mutex + .withLock { + when (val state = lifecycleState) { + is DemuxLifecycleState.Dead -> null + is DemuxLifecycleState.Active -> + state.node.getAndMaybeAddDownstream(key) to + async { + state.node.upstreamConnection.hasCurrentValue( + evalScope.transactionStore + ) + } + is DemuxLifecycleState.Inactive -> { + state.spec + .activate(evalScope, this@DemuxLifecycle) + .also { result -> + lifecycleState = + if (result == null) { + DemuxLifecycleState.Dead + } else { + DemuxLifecycleState.Active(result.first) + } + } + ?.let { (node, needsEval) -> + node.getAndMaybeAddDownstream(key) to + CompletableDeferred(needsEval) + } + } + } + } + ?.let { (branch, result) -> branch to result.await() } + } +} + +internal sealed interface DemuxLifecycleState<out K, out A> { + class Inactive<K, A>(val spec: DemuxActivator<K, A>) : DemuxLifecycleState<K, A> { + override fun toString(): String = "Inactive" + } + + class Active<K, A>(val node: DemuxNode<K, A>) : DemuxLifecycleState<K, A> { + override fun toString(): String = "Active(node=$node)" + } + + data object Dead : DemuxLifecycleState<Nothing, Nothing> +} + +internal interface DemuxActivator<K, A> { + suspend fun activate( + evalScope: EvalScope, + lifecycle: DemuxLifecycle<K, A>, + ): Pair<DemuxNode<K, A>, Boolean>? +} + +internal inline fun <K, A> DemuxLifecycle(onSubscribe: DemuxActivator<K, A>) = + DemuxLifecycle(DemuxLifecycleState.Inactive(onSubscribe)) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/EvalScopeImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/EvalScopeImpl.kt new file mode 100644 index 000000000000..38bc22f1df80 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/EvalScopeImpl.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.FrpDeferredValue +import com.android.systemui.experimental.frp.FrpTransactionScope +import com.android.systemui.experimental.frp.TFlow +import com.android.systemui.experimental.frp.TFlowInit +import com.android.systemui.experimental.frp.TFlowLoop +import com.android.systemui.experimental.frp.TState +import com.android.systemui.experimental.frp.TStateInit +import com.android.systemui.experimental.frp.Transactional +import com.android.systemui.experimental.frp.emptyTFlow +import com.android.systemui.experimental.frp.init +import com.android.systemui.experimental.frp.mapCheap +import com.android.systemui.experimental.frp.switch +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith +import kotlinx.coroutines.job + +internal class EvalScopeImpl(networkScope: NetworkScope, deferScope: DeferScope) : + EvalScope, NetworkScope by networkScope, DeferScope by deferScope { + + private suspend fun <A> Transactional<A>.sample(): A = + impl.sample().sample(this@EvalScopeImpl).await() + + private suspend fun <A> TState<A>.sample(): A = + init.connect(evalScope = this@EvalScopeImpl).getCurrentWithEpoch(this@EvalScopeImpl).first + + private val <A> Transactional<A>.deferredValue: FrpDeferredValue<A> + get() = FrpDeferredValue(deferAsync { sample() }) + + private val <A> TState<A>.deferredValue: FrpDeferredValue<A> + get() = FrpDeferredValue(deferAsync { sample() }) + + private val nowInternal: TFlow<Unit> by lazy { + var result by TFlowLoop<Unit>() + result = + TStateInit( + constInit( + "now", + mkState( + "now", + "now", + this, + { result.mapCheap { emptyTFlow }.init.connect(evalScope = this) }, + CompletableDeferred( + TFlowInit( + constInit( + "now", + TFlowCheap { + ActivationResult( + connection = NodeConnection(AlwaysNode, AlwaysNode), + needsEval = true, + ) + }, + ) + ) + ), + ), + ) + ) + .switch() + result + } + + private fun <R> deferredInternal( + block: suspend FrpTransactionScope.() -> R + ): FrpDeferredValue<R> = FrpDeferredValue(deferAsync { runInTransactionScope(block) }) + + override suspend fun <R> runInTransactionScope(block: suspend FrpTransactionScope.() -> R): R { + val complete = CompletableDeferred<R>(parent = coroutineContext.job) + block.startCoroutine( + frpScope, + object : Continuation<R> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<R>) { + complete.completeWith(result) + } + }, + ) + return complete.await() + } + + override val frpScope: FrpTransactionScope = FrpTransactionScopeImpl() + + inner class FrpTransactionScopeImpl : FrpTransactionScope { + override fun <A> Transactional<A>.sampleDeferred(): FrpDeferredValue<A> = deferredValue + + override fun <A> TState<A>.sampleDeferred(): FrpDeferredValue<A> = deferredValue + + override fun <R> deferredTransactionScope( + block: suspend FrpTransactionScope.() -> R + ): FrpDeferredValue<R> = deferredInternal(block) + + override val now: TFlow<Unit> + get() = nowInternal + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/FilterNode.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/FilterNode.kt new file mode 100644 index 000000000000..4f2a76999bb6 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/FilterNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.none + +internal inline fun <A, B> mapMaybeNode( + crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>, + crossinline f: suspend EvalScope.(A) -> Maybe<B>, +): TFlowImpl<B> { + return DemuxImpl( + { + mapImpl(getPulse) { + val maybeResult = f(it) + if (maybeResult is Just) { + mapOf(Unit to maybeResult.value) + } else { + emptyMap() + } + } + }, + numKeys = 1, + ) + .eventsForKey(Unit) +} + +internal inline fun <A> filterNode( + crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>, + crossinline f: suspend EvalScope.(A) -> Boolean, +): TFlowImpl<A> = mapMaybeNode(getPulse) { if (f(it)) just(it) else none } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Graph.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Graph.kt new file mode 100644 index 000000000000..9425870738fc --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Graph.kt @@ -0,0 +1,530 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Bag +import java.util.TreeMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Tracks all upstream connections for Mux nodes. + * + * Connections come in two flavors: + * 1. **DIRECT** :: The upstream node may emit events that would cause the owner of this depth + * tracker to also emit. + * 2. **INDIRECT** :: The upstream node will not emit events, but may start doing so in a future + * transaction (at which point its depth will change to DIRECT). + * + * DIRECT connections are the standard, active connections that propagate events through the graph. + * They are used to calculate the evaluation depth of a node, so that it is only visited once it is + * certain that all DIRECT upstream connections have already been visited (or are not emitting in + * the current transaction). + * + * It is *invalid* for a node to be directly upstream of itself. Doing so is an error. + * + * INDIRECT connections identify nodes that are still "alive" (should not be garbage-collected) but + * are presently "dormant". This only occurs when a MuxDeferredNode has nothing switched-in, but is + * still connected to its "patches" upstream node, implying that something *may* be switched-in at a + * later time. + * + * It is *invalid* for a node to be indirectly upstream of itself. These connections are + * automatically filtered out. + * + * When there are no connections, either DIRECT or INDIRECT, a node *dies* and all incoming/outgoing + * connections are freed so that it can be garbage-collected. + * + * Note that there is an edge case where a MuxDeferredNode is connected to itself via its "patches" + * upstream node. In this case: + * 1. If the node has switched-in upstream nodes, then this is perfectly valid. Downstream nodes + * will see a direct connection to this MuxDeferredNode. + * 2. Otherwise, the node would normally be considered "dormant" and downstream nodes would see an + * indirect connection. However, because a node cannot be indirectly upstream of itself, then the + * MuxDeferredNode sees no connection via its patches upstream node, and so is considered "dead". + * Conceptually, this makes some sense: The only way for this recursive MuxDeferredNode to become + * non-dormant is to switch some upstream nodes back in, but since the patches node is itself, + * this will never happen. + * + * This behavior underpins the recursive definition of `nextOnly`. + */ +internal class DepthTracker { + + @Volatile var snapshotIsDirect = true + @Volatile private var snapshotIsIndirectRoot = false + + private inline val snapshotIsIndirect: Boolean + get() = !snapshotIsDirect + + @Volatile var snapshotIndirectDepth: Int = 0 + @Volatile var snapshotDirectDepth: Int = 0 + + private val _snapshotIndirectRoots = HashSet<MuxDeferredNode<*, *>>() + val snapshotIndirectRoots + get() = _snapshotIndirectRoots.toSet() + + private val indirectAdditions = HashSet<MuxDeferredNode<*, *>>() + private val indirectRemovals = HashSet<MuxDeferredNode<*, *>>() + private val dirty_directUpstreamDepths = TreeMap<Int, Int>() + private val dirty_indirectUpstreamDepths = TreeMap<Int, Int>() + private val dirty_indirectUpstreamRoots = Bag<MuxDeferredNode<*, *>>() + @Volatile var dirty_directDepth = 0 + @Volatile private var dirty_indirectDepth = 0 + @Volatile private var dirty_depthIsDirect = true + @Volatile private var dirty_isIndirectRoot = false + + suspend fun schedule(scheduler: Scheduler, node: MuxNode<*, *, *>) { + if (dirty_depthIsDirect) { + scheduler.schedule(dirty_directDepth, node) + } else { + scheduler.scheduleIndirect(dirty_indirectDepth, node) + } + } + + // only used by MuxDeferred + // and only when there is a direct connection to the patch node + fun setIsIndirectRoot(isRoot: Boolean): Boolean { + if (isRoot != dirty_isIndirectRoot) { + dirty_isIndirectRoot = isRoot + return !dirty_depthIsDirect + } + return false + } + + // adds an upstream connection, and recalcs depth + // returns true if depth has changed + fun addDirectUpstream(oldDepth: Int?, newDepth: Int): Boolean { + if (oldDepth != null) { + dirty_directUpstreamDepths.compute(oldDepth) { _, count -> + count?.minus(1)?.takeIf { it > 0 } + } + } + dirty_directUpstreamDepths.compute(newDepth) { _, current -> current?.plus(1) ?: 1 } + return recalcDepth() + } + + private fun recalcDepth(): Boolean { + val newDepth = + dirty_directUpstreamDepths.lastEntry()?.let { (maxDepth, _) -> maxDepth + 1 } ?: 0 + + val isDirect = dirty_directUpstreamDepths.isNotEmpty() + val isDirectChanged = dirty_depthIsDirect != isDirect + dirty_depthIsDirect = isDirect + + return (newDepth != dirty_directDepth).also { dirty_directDepth = newDepth } or + isDirectChanged + } + + private fun recalcIndirDepth(): Boolean { + val newDepth = + dirty_indirectUpstreamDepths.lastEntry()?.let { (maxDepth, _) -> maxDepth + 1 } ?: 0 + return (!dirty_depthIsDirect && !dirty_isIndirectRoot && newDepth != dirty_indirectDepth) + .also { dirty_indirectDepth = newDepth } + } + + fun removeDirectUpstream(depth: Int): Boolean { + dirty_directUpstreamDepths.compute(depth) { _, count -> count?.minus(1)?.takeIf { it > 0 } } + return recalcDepth() + } + + fun addIndirectUpstream(oldDepth: Int?, newDepth: Int): Boolean = + if (oldDepth == newDepth) { + false + } else { + if (oldDepth != null) { + dirty_indirectUpstreamDepths.compute(oldDepth) { _, current -> + current?.minus(1)?.takeIf { it > 0 } + } + } + dirty_indirectUpstreamDepths.compute(newDepth) { _, current -> current?.plus(1) ?: 1 } + recalcIndirDepth() + } + + fun removeIndirectUpstream(depth: Int): Boolean { + dirty_indirectUpstreamDepths.compute(depth) { _, current -> + current?.minus(1)?.takeIf { it > 0 } + } + return recalcIndirDepth() + } + + fun updateIndirectRoots( + additions: Set<MuxDeferredNode<*, *>>? = null, + removals: Set<MuxDeferredNode<*, *>>? = null, + butNot: MuxDeferredNode<*, *>? = null, + ): Boolean { + val addsChanged = + additions + ?.let { dirty_indirectUpstreamRoots.addAll(additions, butNot) } + ?.let { + indirectAdditions.addAll(indirectRemovals.applyRemovalDiff(it)) + true + } ?: false + val removalsChanged = + removals + ?.let { dirty_indirectUpstreamRoots.removeAll(removals) } + ?.let { + indirectRemovals.addAll(indirectAdditions.applyRemovalDiff(it)) + true + } ?: false + return (!dirty_depthIsDirect && (addsChanged || removalsChanged)) + } + + private fun <T> HashSet<T>.applyRemovalDiff(changeSet: Set<T>): Set<T> { + val remainder = HashSet<T>() + for (element in changeSet) { + if (!add(element)) { + remainder.add(element) + } + } + return remainder + } + + suspend fun propagateChanges(scheduler: Scheduler, muxNode: MuxNode<*, *, *>) { + if (isDirty()) { + schedule(scheduler, muxNode) + } + } + + fun applyChanges( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + downstreamSet: DownstreamSet, + muxNode: MuxNode<*, *, *>, + ) { + when { + dirty_depthIsDirect -> { + if (snapshotIsDirect) { + downstreamSet.adjustDirectUpstream( + coroutineScope, + scheduler, + oldDepth = snapshotDirectDepth, + newDepth = dirty_directDepth, + ) + } else { + downstreamSet.moveIndirectUpstreamToDirect( + coroutineScope, + scheduler, + oldIndirectDepth = snapshotIndirectDepth, + oldIndirectSet = + buildSet { + addAll(snapshotIndirectRoots) + if (snapshotIsIndirectRoot) { + add(muxNode as MuxDeferredNode<*, *>) + } + }, + newDirectDepth = dirty_directDepth, + ) + } + } + + dirty_hasIndirectUpstream() || dirty_isIndirectRoot -> { + if (snapshotIsDirect) { + downstreamSet.moveDirectUpstreamToIndirect( + coroutineScope, + scheduler, + oldDirectDepth = snapshotDirectDepth, + newIndirectDepth = dirty_indirectDepth, + newIndirectSet = + buildSet { + addAll(dirty_indirectUpstreamRoots) + if (dirty_isIndirectRoot) { + add(muxNode as MuxDeferredNode<*, *>) + } + }, + ) + } else { + downstreamSet.adjustIndirectUpstream( + coroutineScope, + scheduler, + oldDepth = snapshotIndirectDepth, + newDepth = dirty_indirectDepth, + removals = + buildSet { + addAll(indirectRemovals) + if (snapshotIsIndirectRoot && !dirty_isIndirectRoot) { + add(muxNode as MuxDeferredNode<*, *>) + } + }, + additions = + buildSet { + addAll(indirectAdditions) + if (!snapshotIsIndirectRoot && dirty_isIndirectRoot) { + add(muxNode as MuxDeferredNode<*, *>) + } + }, + ) + } + } + + else -> { + // die + muxNode.lifecycle.lifecycleState = MuxLifecycleState.Dead + + if (snapshotIsDirect) { + downstreamSet.removeDirectUpstream( + coroutineScope, + scheduler, + depth = snapshotDirectDepth, + ) + } else { + downstreamSet.removeIndirectUpstream( + coroutineScope, + scheduler, + depth = snapshotIndirectDepth, + indirectSet = + buildSet { + addAll(snapshotIndirectRoots) + if (snapshotIsIndirectRoot) { + add(muxNode as MuxDeferredNode<*, *>) + } + }, + ) + } + downstreamSet.clear() + } + } + reset() + } + + fun dirty_hasDirectUpstream(): Boolean = dirty_directUpstreamDepths.isNotEmpty() + + private fun dirty_hasIndirectUpstream(): Boolean = dirty_indirectUpstreamRoots.isNotEmpty() + + override fun toString(): String = + "DepthTracker(" + + "sIsDirect=$snapshotIsDirect, " + + "sDirectDepth=$snapshotDirectDepth, " + + "sIndirectDepth=$snapshotIndirectDepth, " + + "sIndirectRoots=$snapshotIndirectRoots, " + + "dIsIndirectRoot=$dirty_isIndirectRoot, " + + "dDirectDepths=$dirty_directUpstreamDepths, " + + "dIndirectDepths=$dirty_indirectUpstreamDepths, " + + "dIndirectRoots=$dirty_indirectUpstreamRoots" + + ")" + + fun reset() { + snapshotIsDirect = dirty_hasDirectUpstream() + snapshotDirectDepth = dirty_directDepth + snapshotIndirectDepth = dirty_indirectDepth + snapshotIsIndirectRoot = dirty_isIndirectRoot + if (indirectAdditions.isNotEmpty() || indirectRemovals.isNotEmpty()) { + _snapshotIndirectRoots.clear() + _snapshotIndirectRoots.addAll(dirty_indirectUpstreamRoots) + } + indirectAdditions.clear() + indirectRemovals.clear() + // check(!isDirty()) { "should not be dirty after a reset" } + } + + fun isDirty(): Boolean = + when { + snapshotIsDirect -> !dirty_depthIsDirect || snapshotDirectDepth != dirty_directDepth + snapshotIsIndirectRoot -> dirty_depthIsDirect || !dirty_isIndirectRoot + else -> + dirty_depthIsDirect || + dirty_isIndirectRoot || + snapshotIndirectDepth != dirty_indirectDepth || + indirectAdditions.isNotEmpty() || + indirectRemovals.isNotEmpty() + } + + fun dirty_depthIncreased(): Boolean = + snapshotDirectDepth < dirty_directDepth || snapshotIsIndirect && dirty_hasDirectUpstream() +} + +/** + * Tracks downstream nodes to be scheduled when the owner of this DownstreamSet produces a value in + * a transaction. + */ +internal class DownstreamSet { + + val outputs = HashSet<Output<*>>() + val stateWriters = mutableListOf<TStateSource<*>>() + val muxMovers = HashSet<MuxDeferredNode<*, *>>() + val nodes = HashSet<SchedulableNode>() + + fun add(schedulable: Schedulable) { + when (schedulable) { + is Schedulable.S -> stateWriters.add(schedulable.state) + is Schedulable.M -> muxMovers.add(schedulable.muxMover) + is Schedulable.N -> nodes.add(schedulable.node) + is Schedulable.O -> outputs.add(schedulable.output) + } + } + + fun remove(schedulable: Schedulable) { + when (schedulable) { + is Schedulable.S -> error("WTF: latches are never removed") + is Schedulable.M -> muxMovers.remove(schedulable.muxMover) + is Schedulable.N -> nodes.remove(schedulable.node) + is Schedulable.O -> outputs.remove(schedulable.output) + } + } + + fun adjustDirectUpstream( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + ) = + coroutineScope.run { + for (node in nodes) { + launch { node.adjustDirectUpstream(scheduler, oldDepth, newDepth) } + } + } + + fun moveIndirectUpstreamToDirect( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + newDirectDepth: Int, + ) = + coroutineScope.run { + for (node in nodes) { + launch { + node.moveIndirectUpstreamToDirect( + scheduler, + oldIndirectDepth, + oldIndirectSet, + newDirectDepth, + ) + } + } + for (mover in muxMovers) { + launch { + mover.moveIndirectPatchNodeToDirect(scheduler, oldIndirectDepth, oldIndirectSet) + } + } + } + + fun adjustIndirectUpstream( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) = + coroutineScope.run { + for (node in nodes) { + launch { + node.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions) + } + } + for (mover in muxMovers) { + launch { + mover.adjustIndirectPatchNode( + scheduler, + oldDepth, + newDepth, + removals, + additions, + ) + } + } + } + + fun moveDirectUpstreamToIndirect( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + oldDirectDepth: Int, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) = + coroutineScope.run { + for (node in nodes) { + launch { + node.moveDirectUpstreamToIndirect( + scheduler, + oldDirectDepth, + newIndirectDepth, + newIndirectSet, + ) + } + } + for (mover in muxMovers) { + launch { + mover.moveDirectPatchNodeToIndirect(scheduler, newIndirectDepth, newIndirectSet) + } + } + } + + fun removeIndirectUpstream( + coroutineScope: CoroutineScope, + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) = + coroutineScope.run { + for (node in nodes) { + launch { node.removeIndirectUpstream(scheduler, depth, indirectSet) } + } + for (mover in muxMovers) { + launch { mover.removeIndirectPatchNode(scheduler, depth, indirectSet) } + } + for (output in outputs) { + launch { output.kill() } + } + } + + fun removeDirectUpstream(coroutineScope: CoroutineScope, scheduler: Scheduler, depth: Int) = + coroutineScope.run { + for (node in nodes) { + launch { node.removeDirectUpstream(scheduler, depth) } + } + for (mover in muxMovers) { + launch { mover.removeDirectPatchNode(scheduler) } + } + for (output in outputs) { + launch { output.kill() } + } + } + + fun clear() { + outputs.clear() + stateWriters.clear() + muxMovers.clear() + nodes.clear() + } +} + +// TODO: remove this indirection +internal sealed interface Schedulable { + data class S constructor(val state: TStateSource<*>) : Schedulable + + data class M constructor(val muxMover: MuxDeferredNode<*, *>) : Schedulable + + data class N constructor(val node: SchedulableNode) : Schedulable + + data class O constructor(val output: Output<*>) : Schedulable +} + +internal fun DownstreamSet.isEmpty() = + nodes.isEmpty() && outputs.isEmpty() && muxMovers.isEmpty() && stateWriters.isEmpty() + +@Suppress("NOTHING_TO_INLINE") internal inline fun DownstreamSet.isNotEmpty() = !isEmpty() + +internal fun CoroutineScope.scheduleAll( + downstreamSet: DownstreamSet, + evalScope: EvalScope, +): Boolean { + downstreamSet.nodes.forEach { launch { it.schedule(evalScope) } } + downstreamSet.muxMovers.forEach { launch { it.scheduleMover(evalScope) } } + downstreamSet.outputs.forEach { launch { it.schedule(evalScope) } } + downstreamSet.stateWriters.forEach { evalScope.schedule(it) } + return downstreamSet.isNotEmpty() +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Init.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Init.kt new file mode 100644 index 000000000000..efb7a0951737 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Init.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.none +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi + +/** Performs actions once, when the reactive component is first connected to the network. */ +internal class Init<out A>(val name: String?, private val block: suspend InitScope.() -> A) { + + /** Has the initialization logic been evaluated yet? */ + private val initialized = AtomicBoolean() + + /** + * Stores the result after initialization, as well as the id of the [Network] it's been + * initialized with. + */ + private val cache = CompletableDeferred<Pair<Any, A>>() + + suspend fun connect(evalScope: InitScope): A = + if (initialized.getAndSet(true)) { + // Read from cache + val (networkId, result) = cache.await() + check(networkId == evalScope.networkId) { "Network mismatch" } + result + } else { + // Write to cache + block(evalScope).also { cache.complete(evalScope.networkId to it) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getUnsafe(): Maybe<A> = + if (cache.isCompleted) { + just(cache.getCompleted().second) + } else { + none + } +} + +internal fun <A> init(name: String?, block: suspend InitScope.() -> A) = Init(name, block) + +internal fun <A> constInit(name: String?, value: A) = init(name) { value } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Inputs.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Inputs.kt new file mode 100644 index 000000000000..85c87fea299b --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Inputs.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class InputNode<A>( + private val activate: suspend EvalScope.() -> Unit = {}, + private val deactivate: () -> Unit = {}, +) : PushNode<A>, Key<A> { + + internal val downstreamSet = DownstreamSet() + private val mutex = Mutex() + private val activated = AtomicBoolean(false) + + override val depthTracker: DepthTracker = DepthTracker() + + override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = + transactionStore.contains(this) + + suspend fun visit(evalScope: EvalScope, value: A) { + evalScope.setResult(this, value) + coroutineScope { + if (!mutex.withLock { scheduleAll(downstreamSet, evalScope) }) { + evalScope.scheduleDeactivation(this@InputNode) + } + } + } + + override suspend fun removeDownstream(downstream: Schedulable) { + mutex.withLock { downstreamSet.remove(downstream) } + } + + override suspend fun deactivateIfNeeded() { + if (mutex.withLock { downstreamSet.isEmpty() && activated.getAndSet(false) }) { + deactivate() + } + } + + override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) { + if (mutex.withLock { downstreamSet.isEmpty() }) { + evalScope.scheduleDeactivation(this) + } + } + + override suspend fun addDownstream(downstream: Schedulable) { + mutex.withLock { downstreamSet.add(downstream) } + } + + suspend fun addDownstreamAndActivateIfNeeded(downstream: Schedulable, evalScope: EvalScope) { + val needsActivation = + mutex.withLock { + val wasEmpty = downstreamSet.isEmpty() + downstreamSet.add(downstream) + wasEmpty && !activated.getAndSet(true) + } + if (needsActivation) { + activate(evalScope) + } + } + + override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) { + val needsDeactivation = + mutex.withLock { + downstreamSet.remove(downstream) + downstreamSet.isEmpty() && activated.getAndSet(false) + } + if (needsDeactivation) { + deactivate() + } + } + + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> = + evalScope.getCurrentValue(this) +} + +internal fun <A> InputNode<A>.activated() = TFlowCheap { downstream -> + val input = this@activated + addDownstreamAndActivateIfNeeded(downstream, evalScope = this) + ActivationResult(connection = NodeConnection(input, input), needsEval = hasCurrentValue(input)) +} + +internal data object AlwaysNode : PushNode<Unit> { + + override val depthTracker = DepthTracker() + + override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = true + + override suspend fun removeDownstream(downstream: Schedulable) {} + + override suspend fun deactivateIfNeeded() {} + + override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) {} + + override suspend fun addDownstream(downstream: Schedulable) {} + + override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) {} + + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Unit> = just(Unit) +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/InternalScopes.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/InternalScopes.kt new file mode 100644 index 000000000000..b6cd9063622b --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/InternalScopes.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.FrpBuildScope +import com.android.systemui.experimental.frp.FrpStateScope +import com.android.systemui.experimental.frp.FrpTransactionScope +import com.android.systemui.experimental.frp.TFlow +import com.android.systemui.experimental.frp.internal.util.HeteroMap +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.util.Maybe + +internal interface InitScope { + val networkId: Any +} + +internal interface EvalScope : NetworkScope, DeferScope { + val frpScope: FrpTransactionScope + + suspend fun <R> runInTransactionScope(block: suspend FrpTransactionScope.() -> R): R +} + +internal interface StateScope : EvalScope { + override val frpScope: FrpStateScope + + suspend fun <R> runInStateScope(block: suspend FrpStateScope.() -> R): R + + val endSignal: TFlow<Any> + + fun childStateScope(newEnd: TFlow<Any>): StateScope +} + +internal interface BuildScope : StateScope { + override val frpScope: FrpBuildScope + + suspend fun <R> runInBuildScope(block: suspend FrpBuildScope.() -> R): R +} + +internal interface NetworkScope : InitScope { + + val epoch: Long + val network: Network + + val compactor: Scheduler + val scheduler: Scheduler + + val transactionStore: HeteroMap + + fun scheduleOutput(output: Output<*>) + + fun scheduleMuxMover(muxMover: MuxDeferredNode<*, *>) + + fun schedule(state: TStateSource<*>) + + suspend fun schedule(node: MuxNode<*, *, *>) + + fun scheduleDeactivation(node: PushNode<*>) + + fun scheduleDeactivation(output: Output<*>) +} + +internal fun <A> NetworkScope.setResult(node: Key<A>, result: A) { + transactionStore[node] = result +} + +internal fun <A> NetworkScope.getCurrentValue(key: Key<A>): Maybe<A> = transactionStore[key] + +internal fun NetworkScope.hasCurrentValue(key: Key<*>): Boolean = transactionStore.contains(key) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Mux.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Mux.kt new file mode 100644 index 000000000000..e616d625dd1c --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Mux.kt @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE") + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.ConcurrentNullableHashMap +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.util.Just +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Base class for muxing nodes, which have a potentially dynamic collection of upstream nodes. */ +internal sealed class MuxNode<K : Any, V, Output>(val lifecycle: MuxLifecycle<Output>) : + PushNode<Output> { + + inline val mutex + get() = lifecycle.mutex + + // TODO: preserve insertion order? + val upstreamData = ConcurrentNullableHashMap<K, V>() + val switchedIn = ConcurrentHashMap<K, MuxBranchNode<K, V>>() + val downstreamSet: DownstreamSet = DownstreamSet() + + // TODO: inline DepthTracker? would need to be added to PushNode signature + final override val depthTracker = DepthTracker() + + final override suspend fun addDownstream(downstream: Schedulable) { + mutex.withLock { addDownstreamLocked(downstream) } + } + + /** + * Adds a downstream schedulable to this mux node, such that when this mux node emits a value, + * it will be scheduled for evaluation within this same transaction. + * + * Must only be called when [mutex] is acquired. + */ + fun addDownstreamLocked(downstream: Schedulable) { + downstreamSet.add(downstream) + } + + final override suspend fun removeDownstream(downstream: Schedulable) { + // TODO: return boolean? + mutex.withLock { downstreamSet.remove(downstream) } + } + + final override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) { + val deactivate = + mutex.withLock { + downstreamSet.remove(downstream) + downstreamSet.isEmpty() + } + if (deactivate) { + doDeactivate() + } + } + + final override suspend fun deactivateIfNeeded() { + if (mutex.withLock { downstreamSet.isEmpty() }) { + doDeactivate() + } + } + + /** visit this node from the scheduler (push eval) */ + abstract suspend fun visit(evalScope: EvalScope) + + /** perform deactivation logic, propagating to all upstream nodes. */ + protected abstract suspend fun doDeactivate() + + final override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) { + if (mutex.withLock { downstreamSet.isEmpty() }) { + evalScope.scheduleDeactivation(this) + } + } + + suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) { + mutex.withLock { + if (depthTracker.addDirectUpstream(oldDepth, newDepth)) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun moveIndirectUpstreamToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectRoots: Set<MuxDeferredNode<*, *>>, + newDepth: Int, + ) { + mutex.withLock { + if ( + depthTracker.addDirectUpstream(oldDepth = null, newDepth) or + depthTracker.removeIndirectUpstream(depth = oldIndirectDepth) or + depthTracker.updateIndirectRoots(removals = oldIndirectRoots) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun adjustIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) { + mutex.withLock { + if ( + depthTracker.addIndirectUpstream(oldDepth, newDepth) or + depthTracker.updateIndirectRoots( + additions, + removals, + butNot = this as? MuxDeferredNode<*, *>, + ) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun moveDirectUpstreamToIndirect( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + mutex.withLock { + if ( + depthTracker.addIndirectUpstream(oldDepth = null, newDepth) or + depthTracker.removeDirectUpstream(oldDepth) or + depthTracker.updateIndirectRoots( + additions = newIndirectSet, + butNot = this as? MuxDeferredNode<*, *>, + ) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int, key: K) { + mutex.withLock { + switchedIn.remove(key) + if (depthTracker.removeDirectUpstream(depth)) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun removeIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + key: K, + ) { + mutex.withLock { + switchedIn.remove(key) + if ( + depthTracker.removeIndirectUpstream(oldDepth) or + depthTracker.updateIndirectRoots(removals = indirectSet) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun visitCompact(scheduler: Scheduler) = coroutineScope { + if (depthTracker.isDirty()) { + depthTracker.applyChanges(coroutineScope = this, scheduler, downstreamSet, this@MuxNode) + } + } + + abstract fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean +} + +/** An input branch of a mux node, associated with a key. */ +internal class MuxBranchNode<K : Any, V>(private val muxNode: MuxNode<K, V, *>, val key: K) : + SchedulableNode { + + val schedulable = Schedulable.N(this) + + @Volatile lateinit var upstream: NodeConnection<V> + + override suspend fun schedule(evalScope: EvalScope) { + val upstreamResult = upstream.getPushEvent(evalScope) + if (upstreamResult is Just) { + muxNode.upstreamData[key] = upstreamResult.value + evalScope.schedule(muxNode) + } + } + + override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) { + muxNode.adjustDirectUpstream(scheduler, oldDepth, newDepth) + } + + override suspend fun moveIndirectUpstreamToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + newDirectDepth: Int, + ) { + muxNode.moveIndirectUpstreamToDirect( + scheduler, + oldIndirectDepth, + oldIndirectSet, + newDirectDepth, + ) + } + + override suspend fun adjustIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions) + } + + override suspend fun moveDirectUpstreamToIndirect( + scheduler: Scheduler, + oldDirectDepth: Int, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.moveDirectUpstreamToIndirect( + scheduler, + oldDirectDepth, + newIndirectDepth, + newIndirectSet, + ) + } + + override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) { + muxNode.removeDirectUpstream(scheduler, depth, key) + } + + override suspend fun removeIndirectUpstream( + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.removeIndirectUpstream(scheduler, depth, indirectSet, key) + } + + override fun toString(): String = "MuxBranchNode(key=$key, mux=$muxNode)" +} + +/** Tracks lifecycle of MuxNode in the network. Essentially a mutable ref for MuxLifecycleState. */ +internal class MuxLifecycle<A>(@Volatile var lifecycleState: MuxLifecycleState<A>) : TFlowImpl<A> { + val mutex = Mutex() + + override fun toString(): String = "TFlowLifecycle[$hashString][$lifecycleState][$mutex]" + + override suspend fun activate( + evalScope: EvalScope, + downstream: Schedulable, + ): ActivationResult<A>? = + mutex.withLock { + when (val state = lifecycleState) { + is MuxLifecycleState.Dead -> null + is MuxLifecycleState.Active -> { + state.node.addDownstreamLocked(downstream) + ActivationResult( + connection = NodeConnection(state.node, state.node), + needsEval = state.node.hasCurrentValueLocked(evalScope.transactionStore), + ) + } + is MuxLifecycleState.Inactive -> { + state.spec + .activate(evalScope, this@MuxLifecycle) + .also { node -> + lifecycleState = + if (node == null) { + MuxLifecycleState.Dead + } else { + MuxLifecycleState.Active(node) + } + } + ?.let { node -> + node.addDownstreamLocked(downstream) + ActivationResult( + connection = NodeConnection(node, node), + needsEval = false, + ) + } + } + } + } +} + +internal sealed interface MuxLifecycleState<out A> { + class Inactive<A>(val spec: MuxActivator<A>) : MuxLifecycleState<A> { + override fun toString(): String = "Inactive" + } + + class Active<A>(val node: MuxNode<*, *, A>) : MuxLifecycleState<A> { + override fun toString(): String = "Active(node=$node)" + } + + data object Dead : MuxLifecycleState<Nothing> +} + +internal interface MuxActivator<A> { + suspend fun activate(evalScope: EvalScope, lifecycle: MuxLifecycle<A>): MuxNode<*, *, A>? +} + +internal inline fun <A> MuxLifecycle(onSubscribe: MuxActivator<A>): TFlowImpl<A> = + MuxLifecycle(MuxLifecycleState.Inactive(onSubscribe)) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxDeferred.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxDeferred.kt new file mode 100644 index 000000000000..6d43285c9ef5 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxDeferred.kt @@ -0,0 +1,473 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.internal.util.associateByIndexTo +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.internal.util.mapParallel +import com.android.systemui.experimental.frp.internal.util.mapValuesNotNullParallelTo +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Left +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.Right +import com.android.systemui.experimental.frp.util.These +import com.android.systemui.experimental.frp.util.flatMap +import com.android.systemui.experimental.frp.util.getMaybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.maybeThat +import com.android.systemui.experimental.frp.util.maybeThis +import com.android.systemui.experimental.frp.util.merge +import com.android.systemui.experimental.frp.util.orElseGet +import com.android.systemui.experimental.frp.util.partitionEithers +import com.android.systemui.experimental.frp.util.these +import java.util.TreeMap +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock + +internal class MuxDeferredNode<K : Any, V>( + lifecycle: MuxLifecycle<Map<K, V>>, + val spec: MuxActivator<Map<K, V>>, +) : MuxNode<K, V, Map<K, V>>(lifecycle), Key<Map<K, V>> { + + val schedulable = Schedulable.M(this) + + @Volatile var patches: NodeConnection<Map<K, Maybe<TFlowImpl<V>>>>? = null + @Volatile var patchData: Map<K, Maybe<TFlowImpl<V>>>? = null + + override fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean = + transactionStore.contains(this) + + override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = + mutex.withLock { hasCurrentValueLocked(transactionStore) } + + override suspend fun visit(evalScope: EvalScope) { + val result = upstreamData.toMap() + upstreamData.clear() + val scheduleDownstream = result.isNotEmpty() + val compactDownstream = depthTracker.isDirty() + if (scheduleDownstream || compactDownstream) { + coroutineScope { + mutex.withLock { + if (compactDownstream) { + depthTracker.applyChanges( + coroutineScope = this, + evalScope.scheduler, + downstreamSet, + muxNode = this@MuxDeferredNode, + ) + } + if (scheduleDownstream) { + evalScope.setResult(this@MuxDeferredNode, result) + if (!scheduleAll(downstreamSet, evalScope)) { + evalScope.scheduleDeactivation(this@MuxDeferredNode) + } + } + } + } + } + } + + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Map<K, V>> = + evalScope.getCurrentValue(key = this) + + private suspend fun compactIfNeeded(evalScope: EvalScope) { + depthTracker.propagateChanges(evalScope.compactor, this) + } + + override suspend fun doDeactivate() { + // Update lifecycle + lifecycle.mutex.withLock { + if (lifecycle.lifecycleState !is MuxLifecycleState.Active) return@doDeactivate + lifecycle.lifecycleState = MuxLifecycleState.Inactive(spec) + } + // Process branch nodes + coroutineScope { + switchedIn.values.forEach { branchNode -> + branchNode.upstream.let { + launch { it.removeDownstreamAndDeactivateIfNeeded(branchNode.schedulable) } + } + } + } + // Process patch node + patches?.removeDownstreamAndDeactivateIfNeeded(schedulable) + } + + // MOVE phase + // - concurrent moves may be occurring, but no more evals. all depth recalculations are + // deferred to the end of this phase. + suspend fun performMove(evalScope: EvalScope) { + val patch = patchData ?: return + patchData = null + + // TODO: this logic is very similar to what's in MuxPromptMoving, maybe turn into an inline + // fun? + + // We have a patch, process additions/updates and removals + val (adds, removes) = + patch + .asSequence() + .map { (k, newUpstream: Maybe<TFlowImpl<V>>) -> + when (newUpstream) { + is Just -> Left(k to newUpstream.value) + None -> Right(k) + } + } + .partitionEithers() + + val severed = mutableListOf<NodeConnection<*>>() + + coroutineScope { + // remove and sever + removes.forEach { k -> + switchedIn.remove(k)?.let { branchNode: MuxBranchNode<K, V> -> + val conn = branchNode.upstream + severed.add(conn) + launch { conn.removeDownstream(downstream = branchNode.schedulable) } + depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth) + } + } + + // add or replace + adds + .mapParallel { (k, newUpstream: TFlowImpl<V>) -> + val branchNode = MuxBranchNode(this@MuxDeferredNode, k) + k to + newUpstream.activate(evalScope, branchNode.schedulable)?.let { (conn, _) -> + branchNode.apply { upstream = conn } + } + } + .forEach { (k, newBranch: MuxBranchNode<K, V>?) -> + // remove old and sever, if present + switchedIn.remove(k)?.let { branchNode -> + val conn = branchNode.upstream + severed.add(conn) + launch { conn.removeDownstream(downstream = branchNode.schedulable) } + depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth) + } + + // add new + newBranch?.let { + switchedIn[k] = newBranch + val branchDepthTracker = newBranch.upstream.depthTracker + if (branchDepthTracker.snapshotIsDirect) { + depthTracker.addDirectUpstream( + oldDepth = null, + newDepth = branchDepthTracker.snapshotDirectDepth, + ) + } else { + depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = branchDepthTracker.snapshotIndirectDepth, + ) + depthTracker.updateIndirectRoots( + additions = branchDepthTracker.snapshotIndirectRoots, + butNot = this@MuxDeferredNode, + ) + } + } + } + } + + coroutineScope { + for (severedNode in severed) { + launch { severedNode.scheduleDeactivationIfNeeded(evalScope) } + } + } + + compactIfNeeded(evalScope) + } + + suspend fun removeDirectPatchNode(scheduler: Scheduler) { + mutex.withLock { + if ( + depthTracker.removeIndirectUpstream(depth = 0) or + depthTracker.setIsIndirectRoot(false) + ) { + depthTracker.schedule(scheduler, this) + } + patches = null + } + } + + suspend fun removeIndirectPatchNode( + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) { + // indirectly connected patches forward the indirectSet + mutex.withLock { + if ( + depthTracker.updateIndirectRoots(removals = indirectSet) or + depthTracker.removeIndirectUpstream(depth) + ) { + depthTracker.schedule(scheduler, this) + } + patches = null + } + } + + suspend fun moveIndirectPatchNodeToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + // directly connected patches are stored as an indirect singleton set of the patchNode + mutex.withLock { + if ( + depthTracker.updateIndirectRoots(removals = oldIndirectSet) or + depthTracker.removeIndirectUpstream(oldIndirectDepth) or + depthTracker.setIsIndirectRoot(true) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun moveDirectPatchNodeToIndirect( + scheduler: Scheduler, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + // indirectly connected patches forward the indirectSet + mutex.withLock { + if ( + depthTracker.setIsIndirectRoot(false) or + depthTracker.updateIndirectRoots(additions = newIndirectSet, butNot = this) or + depthTracker.addIndirectUpstream(oldDepth = null, newDepth = newIndirectDepth) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun adjustIndirectPatchNode( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) { + // indirectly connected patches forward the indirectSet + mutex.withLock { + if ( + depthTracker.updateIndirectRoots( + additions = additions, + removals = removals, + butNot = this, + ) or depthTracker.addIndirectUpstream(oldDepth = oldDepth, newDepth = newDepth) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun scheduleMover(evalScope: EvalScope) { + patchData = + checkNotNull(patches) { "mux mover scheduled with unset patches upstream node" } + .getPushEvent(evalScope) + .orElseGet { null } + evalScope.scheduleMuxMover(this) + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +internal inline fun <A> switchDeferredImplSingle( + crossinline getStorage: suspend EvalScope.() -> TFlowImpl<A>, + crossinline getPatches: suspend EvalScope.() -> TFlowImpl<TFlowImpl<A>>, +): TFlowImpl<A> = + mapImpl({ + switchDeferredImpl( + getStorage = { mapOf(Unit to getStorage()) }, + getPatches = { mapImpl(getPatches) { newFlow -> mapOf(Unit to just(newFlow)) } }, + ) + }) { map -> + map.getValue(Unit) + } + +internal fun <K : Any, A> switchDeferredImpl( + getStorage: suspend EvalScope.() -> Map<K, TFlowImpl<A>>, + getPatches: suspend EvalScope.() -> TFlowImpl<Map<K, Maybe<TFlowImpl<A>>>>, +): TFlowImpl<Map<K, A>> = + MuxLifecycle( + object : MuxActivator<Map<K, A>> { + override suspend fun activate( + evalScope: EvalScope, + lifecycle: MuxLifecycle<Map<K, A>>, + ): MuxNode<*, *, Map<K, A>>? { + val storage: Map<K, TFlowImpl<A>> = getStorage(evalScope) + // Initialize mux node and switched-in connections. + val muxNode = + MuxDeferredNode(lifecycle, this).apply { + storage.mapValuesNotNullParallelTo(switchedIn) { (key, flow) -> + val branchNode = MuxBranchNode(this@apply, key) + flow.activate(evalScope, branchNode.schedulable)?.let { + (conn, needsEval) -> + branchNode + .apply { upstream = conn } + .also { + if (needsEval) { + val result = conn.getPushEvent(evalScope) + if (result is Just) { + upstreamData[key] = result.value + } + } + } + } + } + } + // Update depth based on all initial switched-in nodes. + muxNode.switchedIn.values.forEach { branch -> + val conn = branch.upstream + if (conn.depthTracker.snapshotIsDirect) { + muxNode.depthTracker.addDirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotDirectDepth, + ) + } else { + muxNode.depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotIndirectDepth, + ) + muxNode.depthTracker.updateIndirectRoots( + additions = conn.depthTracker.snapshotIndirectRoots, + butNot = muxNode, + ) + } + } + // We don't have our patches connection established yet, so for now pretend we have + // a direct connection to patches. We will update downstream nodes later if this + // turns out to be a lie. + muxNode.depthTracker.setIsIndirectRoot(true) + muxNode.depthTracker.reset() + + // Setup patches connection; deferring allows for a recursive connection, where + // muxNode is downstream of itself via patches. + var isIndirect = true + evalScope.deferAction { + val (patchesConn, needsEval) = + getPatches(evalScope).activate(evalScope, downstream = muxNode.schedulable) + ?: run { + isIndirect = false + // Turns out we can't connect to patches, so update our depth and + // propagate + muxNode.mutex.withLock { + if (muxNode.depthTracker.setIsIndirectRoot(false)) { + muxNode.depthTracker.schedule(evalScope.scheduler, muxNode) + } + } + return@deferAction + } + muxNode.patches = patchesConn + + if (!patchesConn.schedulerUpstream.depthTracker.snapshotIsDirect) { + // Turns out patches is indirect, so we are not a root. Update depth and + // propagate. + muxNode.mutex.withLock { + if ( + muxNode.depthTracker.setIsIndirectRoot(false) or + muxNode.depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = patchesConn.depthTracker.snapshotIndirectDepth, + ) or + muxNode.depthTracker.updateIndirectRoots( + additions = patchesConn.depthTracker.snapshotIndirectRoots + ) + ) { + muxNode.depthTracker.schedule(evalScope.scheduler, muxNode) + } + } + } + // Schedule mover to process patch emission at the end of this transaction, if + // needed. + if (needsEval) { + val result = patchesConn.getPushEvent(evalScope) + if (result is Just) { + muxNode.patchData = result.value + evalScope.scheduleMuxMover(muxNode) + } + } + } + + // Schedule for evaluation if any switched-in nodes have already emitted within + // this transaction. + if (muxNode.upstreamData.isNotEmpty()) { + evalScope.schedule(muxNode) + } + return muxNode.takeUnless { muxNode.switchedIn.isEmpty() && !isIndirect } + } + } + ) + +internal inline fun <A> mergeNodes( + crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>, + crossinline getOther: suspend EvalScope.() -> TFlowImpl<A>, + crossinline f: suspend EvalScope.(A, A) -> A, +): TFlowImpl<A> { + val merged = + mapImpl({ mergeNodes(getPulse, getOther) }) { these -> + these.merge { thiz, that -> f(thiz, that) } + } + return merged.cached() +} + +internal inline fun <A, B> mergeNodes( + crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>, + crossinline getOther: suspend EvalScope.() -> TFlowImpl<B>, +): TFlowImpl<These<A, B>> { + val storage = + mapOf( + 0 to mapImpl(getPulse) { These.thiz<A, B>(it) }, + 1 to mapImpl(getOther) { These.that(it) }, + ) + val switchNode = switchDeferredImpl(getStorage = { storage }, getPatches = { neverImpl }) + val merged = + mapImpl({ switchNode }) { mergeResults -> + val first = mergeResults.getMaybe(0).flatMap { it.maybeThis() } + val second = mergeResults.getMaybe(1).flatMap { it.maybeThat() } + these(first, second).orElseGet { error("unexpected missing merge result") } + } + return merged.cached() +} + +internal inline fun <A> mergeNodes( + crossinline getPulses: suspend EvalScope.() -> Iterable<TFlowImpl<A>> +): TFlowImpl<List<A>> { + val switchNode = + switchDeferredImpl( + getStorage = { getPulses().associateByIndexTo(TreeMap()) }, + getPatches = { neverImpl }, + ) + val merged = mapImpl({ switchNode }) { mergeResults -> mergeResults.values.toList() } + return merged.cached() +} + +internal inline fun <A> mergeNodesLeft( + crossinline getPulses: suspend EvalScope.() -> Iterable<TFlowImpl<A>> +): TFlowImpl<A> { + val switchNode = + switchDeferredImpl( + getStorage = { getPulses().associateByIndexTo(TreeMap()) }, + getPatches = { neverImpl }, + ) + val merged = + mapImpl({ switchNode }) { mergeResults: Map<Int, A> -> mergeResults.values.first() } + return merged.cached() +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxPrompt.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxPrompt.kt new file mode 100644 index 000000000000..ea0c1501f6a4 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/MuxPrompt.kt @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.internal.util.launchImmediate +import com.android.systemui.experimental.frp.internal.util.mapParallel +import com.android.systemui.experimental.frp.internal.util.mapValuesNotNullParallelTo +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Left +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.Right +import com.android.systemui.experimental.frp.util.filterJust +import com.android.systemui.experimental.frp.util.map +import com.android.systemui.experimental.frp.util.partitionEithers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock + +internal class MuxPromptMovingNode<K : Any, V>( + lifecycle: MuxLifecycle<Pair<Map<K, V>, Map<K, PullNode<V>>?>>, + private val spec: MuxActivator<Pair<Map<K, V>, Map<K, PullNode<V>>?>>, +) : + MuxNode<K, V, Pair<Map<K, V>, Map<K, PullNode<V>>?>>(lifecycle), + Key<Pair<Map<K, V>, Map<K, PullNode<V>>?>> { + + @Volatile var patchData: Map<K, Maybe<TFlowImpl<V>>>? = null + @Volatile var patches: MuxPromptPatchNode<K, V>? = null + + @Volatile private var reEval: Pair<Map<K, V>, Map<K, PullNode<V>>?>? = null + + override fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean = + transactionStore.contains(this) + + override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = + mutex.withLock { hasCurrentValueLocked(transactionStore) } + + override suspend fun visit(evalScope: EvalScope) { + val preSwitchResults: Map<K, V> = upstreamData.toMap() + upstreamData.clear() + + val patch: Map<K, Maybe<TFlowImpl<V>>>? = patchData + patchData = null + + val (reschedule, evalResult) = + reEval?.let { false to it } + ?: if (preSwitchResults.isNotEmpty() || patch?.isNotEmpty() == true) { + doEval(preSwitchResults, patch, evalScope) + } else { + false to null + } + reEval = null + + if (reschedule || depthTracker.dirty_depthIncreased()) { + reEval = evalResult + // Can't schedule downstream yet, need to compact first + if (depthTracker.dirty_depthIncreased()) { + depthTracker.schedule(evalScope.compactor, node = this) + } + evalScope.schedule(this) + } else { + val compactDownstream = depthTracker.isDirty() + if (evalResult != null || compactDownstream) { + coroutineScope { + mutex.withLock { + if (compactDownstream) { + adjustDownstreamDepths(evalScope, coroutineScope = this) + } + if (evalResult != null) { + evalScope.setResult(this@MuxPromptMovingNode, evalResult) + if (!scheduleAll(downstreamSet, evalScope)) { + evalScope.scheduleDeactivation(this@MuxPromptMovingNode) + } + } + } + } + } + } + } + + private suspend fun doEval( + preSwitchResults: Map<K, V>, + patch: Map<K, Maybe<TFlowImpl<V>>>?, + evalScope: EvalScope, + ): Pair<Boolean, Pair<Map<K, V>, Map<K, PullNode<V>>?>?> { + val newlySwitchedIn: Map<K, PullNode<V>>? = + patch?.let { + // We have a patch, process additions/updates and removals + val (adds, removes) = + patch + .asSequence() + .map { (k, newUpstream: Maybe<TFlowImpl<V>>) -> + when (newUpstream) { + is Just -> Left(k to newUpstream.value) + None -> Right(k) + } + } + .partitionEithers() + + val additionsAndUpdates = mutableMapOf<K, PullNode<V>>() + val severed = mutableListOf<NodeConnection<*>>() + + coroutineScope { + // remove and sever + removes.forEach { k -> + switchedIn.remove(k)?.let { branchNode: MuxBranchNode<K, V> -> + val conn: NodeConnection<V> = branchNode.upstream + severed.add(conn) + launchImmediate { + conn.removeDownstream(downstream = branchNode.schedulable) + } + depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth) + } + } + + // add or replace + adds + .mapParallel { (k, newUpstream: TFlowImpl<V>) -> + val branchNode = MuxBranchNode(this@MuxPromptMovingNode, k) + k to + newUpstream.activate(evalScope, branchNode.schedulable)?.let { + (conn, _) -> + branchNode.apply { upstream = conn } + } + } + .forEach { (k, newBranch: MuxBranchNode<K, V>?) -> + // remove old and sever, if present + switchedIn.remove(k)?.let { oldBranch: MuxBranchNode<K, V> -> + val conn: NodeConnection<V> = oldBranch.upstream + severed.add(conn) + launchImmediate { + conn.removeDownstream(downstream = oldBranch.schedulable) + } + depthTracker.removeDirectUpstream( + conn.depthTracker.snapshotDirectDepth + ) + } + + // add new + newBranch?.let { + switchedIn[k] = newBranch + additionsAndUpdates[k] = newBranch.upstream.directUpstream + val branchDepthTracker = newBranch.upstream.depthTracker + if (branchDepthTracker.snapshotIsDirect) { + depthTracker.addDirectUpstream( + oldDepth = null, + newDepth = branchDepthTracker.snapshotDirectDepth, + ) + } else { + depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = branchDepthTracker.snapshotIndirectDepth, + ) + depthTracker.updateIndirectRoots( + additions = branchDepthTracker.snapshotIndirectRoots, + butNot = null, + ) + } + } + } + } + + coroutineScope { + for (severedNode in severed) { + launch { severedNode.scheduleDeactivationIfNeeded(evalScope) } + } + } + + additionsAndUpdates.takeIf { it.isNotEmpty() } + } + + return if (preSwitchResults.isNotEmpty() || newlySwitchedIn != null) { + (newlySwitchedIn != null) to (preSwitchResults to newlySwitchedIn) + } else { + false to null + } + } + + private suspend fun adjustDownstreamDepths( + evalScope: EvalScope, + coroutineScope: CoroutineScope, + ) { + if (depthTracker.dirty_depthIncreased()) { + // schedule downstream nodes on the compaction scheduler; this scheduler is drained at + // the end of this eval depth, so that all depth increases are applied before we advance + // the eval step + depthTracker.schedule(evalScope.compactor, node = this@MuxPromptMovingNode) + } else if (depthTracker.isDirty()) { + // schedule downstream nodes on the eval scheduler; this is more efficient and is only + // safe if the depth hasn't increased + depthTracker.applyChanges( + coroutineScope, + evalScope.scheduler, + downstreamSet, + muxNode = this@MuxPromptMovingNode, + ) + } + } + + override suspend fun getPushEvent( + evalScope: EvalScope + ): Maybe<Pair<Map<K, V>, Map<K, PullNode<V>>?>> = evalScope.getCurrentValue(key = this) + + override suspend fun doDeactivate() { + // Update lifecycle + lifecycle.mutex.withLock { + if (lifecycle.lifecycleState !is MuxLifecycleState.Active) return@doDeactivate + lifecycle.lifecycleState = MuxLifecycleState.Inactive(spec) + } + // Process branch nodes + switchedIn.values.forEach { branchNode -> + branchNode.upstream.removeDownstreamAndDeactivateIfNeeded( + downstream = branchNode.schedulable + ) + } + // Process patch node + patches?.let { patches -> + patches.upstream.removeDownstreamAndDeactivateIfNeeded(downstream = patches.schedulable) + } + } + + suspend fun removeIndirectPatchNode( + scheduler: Scheduler, + oldDepth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) { + mutex.withLock { + patches = null + if ( + depthTracker.removeIndirectUpstream(oldDepth) or + depthTracker.updateIndirectRoots(removals = indirectSet) + ) { + depthTracker.schedule(scheduler, this) + } + } + } + + suspend fun removeDirectPatchNode(scheduler: Scheduler, depth: Int) { + mutex.withLock { + patches = null + if (depthTracker.removeDirectUpstream(depth)) { + depthTracker.schedule(scheduler, this) + } + } + } +} + +internal class MuxPromptEvalNode<K, V>( + private val movingNode: PullNode<Pair<Map<K, V>, Map<K, PullNode<V>>?>> +) : PullNode<Map<K, V>> { + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Map<K, V>> = + movingNode.getPushEvent(evalScope).map { (preSwitchResults, newlySwitchedIn) -> + coroutineScope { + newlySwitchedIn + ?.map { (k, v) -> async { v.getPushEvent(evalScope).map { k to it } } } + ?.awaitAll() + ?.asSequence() + ?.filterJust() + ?.toMap(preSwitchResults.toMutableMap()) ?: preSwitchResults + } + } +} + +// TODO: inner class? +internal class MuxPromptPatchNode<K : Any, V>(private val muxNode: MuxPromptMovingNode<K, V>) : + SchedulableNode { + + val schedulable = Schedulable.N(this) + + lateinit var upstream: NodeConnection<Map<K, Maybe<TFlowImpl<V>>>> + + override suspend fun schedule(evalScope: EvalScope) { + val upstreamResult = upstream.getPushEvent(evalScope) + if (upstreamResult is Just) { + muxNode.patchData = upstreamResult.value + evalScope.schedule(muxNode) + } + } + + override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) { + muxNode.adjustDirectUpstream(scheduler, oldDepth, newDepth) + } + + override suspend fun moveIndirectUpstreamToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + newDirectDepth: Int, + ) { + muxNode.moveIndirectUpstreamToDirect( + scheduler, + oldIndirectDepth, + oldIndirectSet, + newDirectDepth, + ) + } + + override suspend fun adjustIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions) + } + + override suspend fun moveDirectUpstreamToIndirect( + scheduler: Scheduler, + oldDirectDepth: Int, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.moveDirectUpstreamToIndirect( + scheduler, + oldDirectDepth, + newIndirectDepth, + newIndirectSet, + ) + } + + override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) { + muxNode.removeDirectPatchNode(scheduler, depth) + } + + override suspend fun removeIndirectUpstream( + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) { + muxNode.removeIndirectPatchNode(scheduler, depth, indirectSet) + } +} + +internal fun <K : Any, A> switchPromptImpl( + getStorage: suspend EvalScope.() -> Map<K, TFlowImpl<A>>, + getPatches: suspend EvalScope.() -> TFlowImpl<Map<K, Maybe<TFlowImpl<A>>>>, +): TFlowImpl<Map<K, A>> { + val moving = + MuxLifecycle( + object : MuxActivator<Pair<Map<K, A>, Map<K, PullNode<A>>?>> { + override suspend fun activate( + evalScope: EvalScope, + lifecycle: MuxLifecycle<Pair<Map<K, A>, Map<K, PullNode<A>>?>>, + ): MuxNode<*, *, Pair<Map<K, A>, Map<K, PullNode<A>>?>>? { + val storage: Map<K, TFlowImpl<A>> = getStorage(evalScope) + // Initialize mux node and switched-in connections. + val movingNode = + MuxPromptMovingNode(lifecycle, this).apply { + coroutineScope { + launch { + storage.mapValuesNotNullParallelTo(switchedIn) { (key, flow) -> + val branchNode = MuxBranchNode(this@apply, key) + flow + .activate( + evalScope = evalScope, + downstream = branchNode.schedulable, + ) + ?.let { (conn, needsEval) -> + branchNode + .apply { upstream = conn } + .also { + if (needsEval) { + val result = + conn.getPushEvent(evalScope) + if (result is Just) { + upstreamData[key] = result.value + } + } + } + } + } + } + // Setup patches connection + val patchNode = MuxPromptPatchNode(this@apply) + getPatches(evalScope) + .activate( + evalScope = evalScope, + downstream = patchNode.schedulable, + ) + ?.let { (conn, needsEval) -> + patchNode.upstream = conn + patches = patchNode + + if (needsEval) { + val result = conn.getPushEvent(evalScope) + if (result is Just) { + patchData = result.value + } + } + } + } + } + // Update depth based on all initial switched-in nodes. + movingNode.switchedIn.values.forEach { branch -> + val conn = branch.upstream + if (conn.depthTracker.snapshotIsDirect) { + movingNode.depthTracker.addDirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotDirectDepth, + ) + } else { + movingNode.depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotIndirectDepth, + ) + movingNode.depthTracker.updateIndirectRoots( + additions = conn.depthTracker.snapshotIndirectRoots, + butNot = null, + ) + } + } + // Update depth based on patches node. + movingNode.patches?.upstream?.let { conn -> + if (conn.depthTracker.snapshotIsDirect) { + movingNode.depthTracker.addDirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotDirectDepth, + ) + } else { + movingNode.depthTracker.addIndirectUpstream( + oldDepth = null, + newDepth = conn.depthTracker.snapshotIndirectDepth, + ) + movingNode.depthTracker.updateIndirectRoots( + additions = conn.depthTracker.snapshotIndirectRoots, + butNot = null, + ) + } + } + movingNode.depthTracker.reset() + + // Schedule for evaluation if any switched-in nodes or the patches node have + // already emitted within this transaction. + if (movingNode.patchData != null || movingNode.upstreamData.isNotEmpty()) { + evalScope.schedule(movingNode) + } + + return movingNode.takeUnless { it.patches == null && it.switchedIn.isEmpty() } + } + } + ) + + val eval = TFlowCheap { downstream -> + moving.activate(evalScope = this, downstream)?.let { (connection, needsEval) -> + val evalNode = MuxPromptEvalNode(connection.directUpstream) + ActivationResult( + connection = NodeConnection(evalNode, connection.schedulerUpstream), + needsEval = needsEval, + ) + } + } + return eval.cached() +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Network.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Network.kt new file mode 100644 index 000000000000..b5ffe75eb9f4 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Network.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.TState +import com.android.systemui.experimental.frp.internal.util.HeteroMap +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.none +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.ContinuationInterceptor +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield + +private val nextNetworkId = AtomicLong() + +internal class Network(val coroutineScope: CoroutineScope) : NetworkScope { + + override val networkId: Any = nextNetworkId.getAndIncrement() + + @Volatile + override var epoch: Long = 0L + private set + + override val network + get() = this + + override val compactor = SchedulerImpl() + override val scheduler = SchedulerImpl() + override val transactionStore = HeteroMap() + + private val stateWrites = ConcurrentLinkedQueue<TStateSource<*>>() + private val outputsByDispatcher = + ConcurrentHashMap<ContinuationInterceptor, ConcurrentLinkedQueue<Output<*>>>() + private val muxMovers = ConcurrentLinkedQueue<MuxDeferredNode<*, *>>() + private val deactivations = ConcurrentLinkedDeque<PushNode<*>>() + private val outputDeactivations = ConcurrentLinkedQueue<Output<*>>() + private val transactionMutex = Mutex() + private val inputScheduleChan = Channel<ScheduledAction<*>>() + + override fun scheduleOutput(output: Output<*>) { + val continuationInterceptor = + output.context[ContinuationInterceptor] ?: Dispatchers.Unconfined + outputsByDispatcher + .computeIfAbsent(continuationInterceptor) { ConcurrentLinkedQueue() } + .add(output) + } + + override fun scheduleMuxMover(muxMover: MuxDeferredNode<*, *>) { + muxMovers.add(muxMover) + } + + override fun schedule(state: TStateSource<*>) { + stateWrites.add(state) + } + + // TODO: weird that we have this *and* scheduler exposed + override suspend fun schedule(node: MuxNode<*, *, *>) { + scheduler.schedule(node.depthTracker.dirty_directDepth, node) + } + + override fun scheduleDeactivation(node: PushNode<*>) { + deactivations.add(node) + } + + override fun scheduleDeactivation(output: Output<*>) { + outputDeactivations.add(output) + } + + /** Listens for external events and starts FRP transactions. Runs forever. */ + suspend fun runInputScheduler() = coroutineScope { + launch { scheduler.activate() } + launch { compactor.activate() } + val actions = mutableListOf<ScheduledAction<*>>() + for (first in inputScheduleChan) { + // Drain and conflate all transaction requests into a single transaction + actions.add(first) + while (true) { + yield() + val func = inputScheduleChan.tryReceive().getOrNull() ?: break + actions.add(func) + } + transactionMutex.withLock { + // Run all actions + evalScope { + for (action in actions) { + launch { action.started(evalScope = this@evalScope) } + } + } + // Step through the network + doTransaction() + // Signal completion + while (actions.isNotEmpty()) { + actions.removeLast().completed() + } + } + } + } + + /** Evaluates [block] inside of a new transaction when the network is ready. */ + fun <R> transaction(block: suspend EvalScope.() -> R): Deferred<R> = + CompletableDeferred<R>(parent = coroutineScope.coroutineContext.job).also { onResult -> + val job = + coroutineScope.launch { + inputScheduleChan.send( + ScheduledAction(onStartTransaction = block, onResult = onResult) + ) + } + onResult.invokeOnCompletion { job.cancel() } + } + + suspend fun <R> evalScope(block: suspend EvalScope.() -> R): R = deferScope { + block(EvalScopeImpl(this@Network, this)) + } + + /** Performs a transactional update of the FRP network. */ + private suspend fun doTransaction() { + // Traverse network, then run outputs + do { + scheduler.drainEval(this) + } while (evalScope { evalOutputs(this) }) + // Update states + evalScope { evalStateWriters(this) } + transactionStore.clear() + // Perform deferred switches + evalScope { evalMuxMovers(this) } + // Compact depths + scheduler.drainCompact() + compactor.drainCompact() + // Deactivate nodes with no downstream + evalDeactivations() + epoch++ + } + + /** Invokes all [Output]s that have received data within this transaction. */ + private suspend fun evalOutputs(evalScope: EvalScope): Boolean { + // Outputs can enqueue other outputs, so we need two loops + if (outputsByDispatcher.isEmpty()) return false + while (outputsByDispatcher.isNotEmpty()) { + var launchedAny = false + coroutineScope { + for ((key, outputs) in outputsByDispatcher) { + if (outputs.isNotEmpty()) { + launchedAny = true + launch(key) { + while (outputs.isNotEmpty()) { + val output = outputs.remove() + launch { output.visit(evalScope) } + } + } + } + } + } + if (!launchedAny) outputsByDispatcher.clear() + } + return true + } + + private suspend fun evalMuxMovers(evalScope: EvalScope) { + while (muxMovers.isNotEmpty()) { + coroutineScope { + val toMove = muxMovers.remove() + launch { toMove.performMove(evalScope) } + } + } + } + + /** Updates all [TState]es that have changed within this transaction. */ + private suspend fun evalStateWriters(evalScope: EvalScope) { + coroutineScope { + while (stateWrites.isNotEmpty()) { + val latch = stateWrites.remove() + launch { latch.updateState(evalScope) } + } + } + } + + private suspend fun evalDeactivations() { + coroutineScope { + launch { + while (deactivations.isNotEmpty()) { + // traverse in reverse order + // - deactivations are added in depth-order during the node traversal phase + // - perform deactivations in reverse order, in case later ones propagate to + // earlier ones + val toDeactivate = deactivations.removeLast() + launch { toDeactivate.deactivateIfNeeded() } + } + } + while (outputDeactivations.isNotEmpty()) { + val toDeactivate = outputDeactivations.remove() + launch { + toDeactivate.upstream?.removeDownstreamAndDeactivateIfNeeded( + downstream = toDeactivate.schedulable + ) + } + } + } + check(deactivations.isEmpty()) { "unexpected lingering deactivations" } + check(outputDeactivations.isEmpty()) { "unexpected lingering output deactivations" } + } +} + +internal class ScheduledAction<T>( + private val onResult: CompletableDeferred<T>? = null, + private val onStartTransaction: suspend EvalScope.() -> T, +) { + private var result: Maybe<T> = none + + suspend fun started(evalScope: EvalScope) { + result = just(onStartTransaction(evalScope)) + } + + fun completed() { + if (onResult != null) { + when (val result = result) { + is Just -> onResult.complete(result.value) + else -> {} + } + } + result = none + } +} + +internal typealias TransactionStore = HeteroMap diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NoScope.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NoScope.kt new file mode 100644 index 000000000000..6375918f35cd --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NoScope.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.FrpScope +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.coroutineContext +import kotlin.coroutines.startCoroutine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith +import kotlinx.coroutines.job + +internal object NoScope { + private object FrpScopeImpl : FrpScope + + suspend fun <R> runInFrpScope(block: suspend FrpScope.() -> R): R { + val complete = CompletableDeferred<R>(coroutineContext.job) + block.startCoroutine( + FrpScopeImpl, + object : Continuation<R> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<R>) { + complete.completeWith(result) + } + }, + ) + return complete.await() + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NodeTypes.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NodeTypes.kt new file mode 100644 index 000000000000..e7f76a08b638 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/NodeTypes.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.util.Maybe + +/* +Dmux +Muxes + Branch +*/ +internal sealed interface SchedulableNode { + /** schedule this node w/ given NodeEvalScope */ + suspend fun schedule(evalScope: EvalScope) + + suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) + + suspend fun moveIndirectUpstreamToDirect( + scheduler: Scheduler, + oldIndirectDepth: Int, + oldIndirectSet: Set<MuxDeferredNode<*, *>>, + newDirectDepth: Int, + ) + + suspend fun adjustIndirectUpstream( + scheduler: Scheduler, + oldDepth: Int, + newDepth: Int, + removals: Set<MuxDeferredNode<*, *>>, + additions: Set<MuxDeferredNode<*, *>>, + ) + + suspend fun moveDirectUpstreamToIndirect( + scheduler: Scheduler, + oldDirectDepth: Int, + newIndirectDepth: Int, + newIndirectSet: Set<MuxDeferredNode<*, *>>, + ) + + suspend fun removeIndirectUpstream( + scheduler: Scheduler, + depth: Int, + indirectSet: Set<MuxDeferredNode<*, *>>, + ) + + suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) +} + +/* +All but Dmux + */ +internal sealed interface PullNode<out A> { + /** + * query the result of this node within the current transaction. if the node is cached, this + * will read from the cache, otherwise it will perform a full evaluation, even if invoked + * multiple times within a transaction. + */ + suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> +} + +/* +Muxes + DmuxBranch + */ +internal sealed interface PushNode<A> : PullNode<A> { + + suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean + + val depthTracker: DepthTracker + + suspend fun removeDownstream(downstream: Schedulable) + + /** called during cleanup phase */ + suspend fun deactivateIfNeeded() + + /** called from mux nodes after severs */ + suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) + + suspend fun addDownstream(downstream: Schedulable) + + suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Output.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Output.kt new file mode 100644 index 000000000000..e60dccaac392 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Output.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.util.Just +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +internal class Output<A>( + val context: CoroutineContext = EmptyCoroutineContext, + val onDeath: suspend () -> Unit = {}, + val onEmit: suspend EvalScope.(A) -> Unit, +) { + + val schedulable = Schedulable.O(this) + + @Volatile var upstream: NodeConnection<A>? = null + @Volatile var result: Any? = NoResult + + private object NoResult + + // invoked by network + suspend fun visit(evalScope: EvalScope) { + val upstreamResult = result + check(upstreamResult !== NoResult) { "output visited with null upstream result" } + result = null + @Suppress("UNCHECKED_CAST") evalScope.onEmit(upstreamResult as A) + } + + suspend fun kill() { + onDeath() + } + + suspend fun schedule(evalScope: EvalScope) { + val upstreamResult = + checkNotNull(upstream) { "output scheduled with null upstream" }.getPushEvent(evalScope) + if (upstreamResult is Just) { + result = upstreamResult.value + evalScope.scheduleOutput(this) + } + } +} + +internal inline fun OneShot(crossinline onEmit: suspend EvalScope.() -> Unit): Output<Unit> = + Output<Unit>(onEmit = { onEmit() }).apply { result = Unit } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/PullNodes.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/PullNodes.kt new file mode 100644 index 000000000000..b4656e0aee3a --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/PullNodes.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.map +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred + +internal val neverImpl: TFlowImpl<Nothing> = TFlowCheap { null } + +internal class MapNode<A, B>(val upstream: PullNode<A>, val transform: suspend EvalScope.(A) -> B) : + PullNode<B> { + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<B> = + upstream.getPushEvent(evalScope).map { evalScope.transform(it) } +} + +internal inline fun <A, B> mapImpl( + crossinline upstream: suspend EvalScope.() -> TFlowImpl<A>, + noinline transform: suspend EvalScope.(A) -> B, +): TFlowImpl<B> = TFlowCheap { downstream -> + upstream().activate(evalScope = this, downstream)?.let { (connection, needsEval) -> + ActivationResult( + connection = + NodeConnection( + directUpstream = MapNode(connection.directUpstream, transform), + schedulerUpstream = connection.schedulerUpstream, + ), + needsEval = needsEval, + ) + } +} + +internal class CachedNode<A>(val key: Key<Deferred<Maybe<A>>>, val upstream: PullNode<A>) : + PullNode<A> { + override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> { + val deferred = + evalScope.transactionStore.getOrPut(key) { + evalScope.deferAsync(CoroutineStart.LAZY) { upstream.getPushEvent(evalScope) } + } + return deferred.await() + } +} + +internal fun <A> TFlowImpl<A>.cached(): TFlowImpl<A> { + val key = object : Key<Deferred<Maybe<A>>> {} + return TFlowCheap { + activate(this, it)?.let { (connection, needsEval) -> + ActivationResult( + connection = + NodeConnection( + directUpstream = CachedNode(key, connection.directUpstream), + schedulerUpstream = connection.schedulerUpstream, + ), + needsEval = needsEval, + ) + } + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Scheduler.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Scheduler.kt new file mode 100644 index 000000000000..4fef865e87e7 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/Scheduler.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.experimental.frp.internal + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.PriorityBlockingQueue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +internal interface Scheduler { + suspend fun schedule(depth: Int, node: MuxNode<*, *, *>) + + suspend fun scheduleIndirect(indirectDepth: Int, node: MuxNode<*, *, *>) +} + +internal class SchedulerImpl : Scheduler { + val enqueued = ConcurrentHashMap<MuxNode<*, *, *>, Any>() + val scheduledQ = PriorityBlockingQueue<Pair<Int, MuxNode<*, *, *>>>(16, compareBy { it.first }) + val chan = Channel<Pair<Int, MuxNode<*, *, *>>>(Channel.UNLIMITED) + + override suspend fun schedule(depth: Int, node: MuxNode<*, *, *>) { + if (enqueued.putIfAbsent(node, node) == null) { + chan.send(Pair(depth, node)) + } + } + + override suspend fun scheduleIndirect(indirectDepth: Int, node: MuxNode<*, *, *>) { + schedule(Int.MIN_VALUE + indirectDepth, node) + } + + suspend fun activate() { + for (nodeSchedule in chan) { + scheduledQ.add(nodeSchedule) + drainChan() + } + } + + internal suspend fun drainEval(network: Network) { + drain { runStep -> + runStep { muxNode -> network.evalScope { muxNode.visit(this) } } + // If any visited MuxPromptNodes had their depths increased, eagerly propagate those + // depth + // changes now before performing further network evaluation. + network.compactor.drainCompact() + } + } + + internal suspend fun drainCompact() { + drain { runStep -> runStep { muxNode -> muxNode.visitCompact(scheduler = this) } } + } + + private suspend inline fun drain( + crossinline onStep: + suspend (runStep: suspend (visit: suspend (MuxNode<*, *, *>) -> Unit) -> Unit) -> Unit + ): Unit = coroutineScope { + while (!chan.isEmpty || scheduledQ.isNotEmpty()) { + drainChan() + val maxDepth = scheduledQ.peek()?.first ?: error("Unexpected empty scheduler") + onStep { visit -> runStep(maxDepth, visit) } + } + } + + private suspend fun drainChan() { + while (!chan.isEmpty) { + scheduledQ.add(chan.receive()) + } + } + + private suspend inline fun runStep( + maxDepth: Int, + crossinline visit: suspend (MuxNode<*, *, *>) -> Unit, + ) = coroutineScope { + while (scheduledQ.peek()?.first?.let { it <= maxDepth } == true) { + val (d, node) = scheduledQ.remove() + if ( + node.depthTracker.dirty_hasDirectUpstream() && + d < node.depthTracker.dirty_directDepth + ) { + scheduledQ.add(node.depthTracker.dirty_directDepth to node) + } else { + launch { + enqueued.remove(node) + visit(node) + } + } + } + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/StateScopeImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/StateScopeImpl.kt new file mode 100644 index 000000000000..c1d10760978a --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/StateScopeImpl.kt @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.FrpDeferredValue +import com.android.systemui.experimental.frp.FrpStateScope +import com.android.systemui.experimental.frp.FrpStateful +import com.android.systemui.experimental.frp.FrpTransactionScope +import com.android.systemui.experimental.frp.GroupedTFlow +import com.android.systemui.experimental.frp.TFlow +import com.android.systemui.experimental.frp.TFlowInit +import com.android.systemui.experimental.frp.TFlowLoop +import com.android.systemui.experimental.frp.TState +import com.android.systemui.experimental.frp.TStateInit +import com.android.systemui.experimental.frp.emptyTFlow +import com.android.systemui.experimental.frp.groupByKey +import com.android.systemui.experimental.frp.init +import com.android.systemui.experimental.frp.internal.util.mapValuesParallel +import com.android.systemui.experimental.frp.mapCheap +import com.android.systemui.experimental.frp.merge +import com.android.systemui.experimental.frp.switch +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.map +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.completeWith +import kotlinx.coroutines.job + +internal class StateScopeImpl(val evalScope: EvalScope, override val endSignal: TFlow<Any>) : + StateScope, EvalScope by evalScope { + + private val endSignalOnce: TFlow<Any> = endSignal.nextOnlyInternal("StateScope.endSignal") + + private fun <A> TFlow<A>.truncateToScope(operatorName: String): TFlow<A> = + if (endSignalOnce === emptyTFlow) { + this + } else { + endSignalOnce.mapCheap { emptyTFlow }.toTStateInternal(operatorName, this).switch() + } + + private fun <A> TFlow<A>.nextOnlyInternal(operatorName: String): TFlow<A> = + if (this === emptyTFlow) { + this + } else { + TFlowLoop<A>().apply { + loopback = + mapCheap { emptyTFlow } + .toTStateInternal(operatorName, this@nextOnlyInternal) + .switch() + } + } + + private fun <A> TFlow<A>.toTStateInternal(operatorName: String, init: A): TState<A> = + toTStateInternalDeferred(operatorName, CompletableDeferred(init)) + + private fun <A> TFlow<A>.toTStateInternalDeferred( + operatorName: String, + init: Deferred<A>, + ): TState<A> { + val changes = this@toTStateInternalDeferred + val name = operatorName + val impl = + mkState(name, operatorName, evalScope, { changes.init.connect(evalScope = this) }, init) + return TStateInit(constInit(name, impl)) + } + + private fun <R> deferredInternal(block: suspend FrpStateScope.() -> R): FrpDeferredValue<R> = + FrpDeferredValue(deferAsync { runInStateScope(block) }) + + private fun <A> TFlow<A>.toTStateDeferredInternal( + initialValue: FrpDeferredValue<A> + ): TState<A> { + val operatorName = "toTStateDeferred" + // Ensure state is only collected until the end of this scope + return truncateToScope(operatorName) + .toTStateInternalDeferred(operatorName, initialValue.unwrapped) + } + + private fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyInternal( + storage: TState<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> { + val name = "mergeIncrementally" + return TFlowInit( + constInit( + name, + switchDeferredImpl( + getStorage = { + storage.init + .connect(this) + .getCurrentWithEpoch(this) + .first + .mapValuesParallel { (_, flow) -> flow.init.connect(this) } + }, + getPatches = { + mapImpl({ init.connect(this) }) { patch -> + patch.mapValuesParallel { (_, m) -> + m.map { flow -> flow.init.connect(this) } + } + } + }, + ), + ) + ) + } + + private fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptInternal( + storage: TState<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> { + val name = "mergeIncrementallyPrompt" + return TFlowInit( + constInit( + name, + switchPromptImpl( + getStorage = { + storage.init + .connect(this) + .getCurrentWithEpoch(this) + .first + .mapValuesParallel { (_, flow) -> flow.init.connect(this) } + }, + getPatches = { + mapImpl({ init.connect(this) }) { patch -> + patch.mapValuesParallel { (_, m) -> + m.map { flow -> flow.init.connect(this) } + } + } + }, + ), + ) + ) + } + + private fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKeyInternal( + init: FrpDeferredValue<Map<K, FrpStateful<B>>>, + numKeys: Int?, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> { + val eventsByKey: GroupedTFlow<K, Maybe<FrpStateful<A>>> = groupByKey(numKeys) + val initOut: Deferred<Map<K, B>> = deferAsync { + init.unwrapped.await().mapValuesParallel { (k, stateful) -> + val newEnd = with(frpScope) { eventsByKey[k].skipNext() } + val newScope = childStateScope(newEnd) + newScope.runInStateScope(stateful) + } + } + val changesNode: TFlowImpl<Map<K, Maybe<A>>> = + mapImpl( + upstream = { this@applyLatestStatefulForKeyInternal.init.connect(evalScope = this) } + ) { upstreamMap -> + upstreamMap.mapValuesParallel { (k: K, ma: Maybe<FrpStateful<A>>) -> + reenterStateScope(this@StateScopeImpl).run { + ma.map { stateful -> + val newEnd = with(frpScope) { eventsByKey[k].skipNext() } + val newScope = childStateScope(newEnd) + newScope.runInStateScope(stateful) + } + } + } + } + val operatorName = "applyLatestStatefulForKey" + val name = operatorName + val changes: TFlow<Map<K, Maybe<A>>> = TFlowInit(constInit(name, changesNode.cached())) + return changes to FrpDeferredValue(initOut) + } + + private fun <A> TFlow<FrpStateful<A>>.observeStatefulsInternal(): TFlow<A> { + val operatorName = "observeStatefuls" + val name = operatorName + return TFlowInit( + constInit( + name, + mapImpl( + upstream = { this@observeStatefulsInternal.init.connect(evalScope = this) } + ) { stateful -> + reenterStateScope(outerScope = this@StateScopeImpl) + .runInStateScope(stateful) + } + .cached(), + ) + ) + } + + override val frpScope: FrpStateScope = FrpStateScopeImpl() + + private inner class FrpStateScopeImpl : + FrpStateScope, FrpTransactionScope by evalScope.frpScope { + + override fun <A> deferredStateScope( + block: suspend FrpStateScope.() -> A + ): FrpDeferredValue<A> = deferredInternal(block) + + override fun <A> TFlow<A>.holdDeferred(initialValue: FrpDeferredValue<A>): TState<A> = + toTStateDeferredInternal(initialValue) + + override fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally( + initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> { + val storage: TState<Map<K, TFlow<V>>> = foldMapIncrementally(initialTFlows) + return mergeIncrementallyInternal(storage) + } + + override fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly( + initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>> + ): TFlow<Map<K, V>> { + val storage: TState<Map<K, TFlow<V>>> = foldMapIncrementally(initialTFlows) + return mergeIncrementallyPromptInternal(storage) + } + + override fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey( + init: FrpDeferredValue<Map<K, FrpStateful<B>>>, + numKeys: Int?, + ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> = + applyLatestStatefulForKeyInternal(init, numKeys) + + override fun <A> TFlow<FrpStateful<A>>.applyStatefuls(): TFlow<A> = + observeStatefulsInternal() + } + + override suspend fun <R> runInStateScope(block: suspend FrpStateScope.() -> R): R { + val complete = CompletableDeferred<R>(parent = coroutineContext.job) + block.startCoroutine( + frpScope, + object : Continuation<R> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<R>) { + complete.completeWith(result) + } + }, + ) + return complete.await() + } + + override fun childStateScope(newEnd: TFlow<Any>) = + StateScopeImpl(evalScope, merge(newEnd, endSignal)) +} + +private fun EvalScope.reenterStateScope(outerScope: StateScopeImpl) = + StateScopeImpl(evalScope = this, endSignal = outerScope.endSignal) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TFlowImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TFlowImpl.kt new file mode 100644 index 000000000000..79978640d5a8 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TFlowImpl.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.util.Maybe + +/* Initialized TFlow */ +internal fun interface TFlowImpl<out A> { + suspend fun activate(evalScope: EvalScope, downstream: Schedulable): ActivationResult<A>? +} + +internal data class ActivationResult<out A>( + val connection: NodeConnection<A>, + val needsEval: Boolean, +) + +internal inline fun <A> TFlowCheap(crossinline cheap: CheapNodeSubscribe<A>) = + TFlowImpl { scope, ds -> + scope.cheap(ds) + } + +internal typealias CheapNodeSubscribe<A> = + suspend EvalScope.(downstream: Schedulable) -> ActivationResult<A>? + +internal data class NodeConnection<out A>( + val directUpstream: PullNode<A>, + val schedulerUpstream: PushNode<*>, +) + +internal suspend fun <A> NodeConnection<A>.hasCurrentValue( + transactionStore: TransactionStore +): Boolean = schedulerUpstream.hasCurrentValue(transactionStore) + +internal suspend fun <A> NodeConnection<A>.removeDownstreamAndDeactivateIfNeeded( + downstream: Schedulable +) = schedulerUpstream.removeDownstreamAndDeactivateIfNeeded(downstream) + +internal suspend fun <A> NodeConnection<A>.scheduleDeactivationIfNeeded(evalScope: EvalScope) = + schedulerUpstream.scheduleDeactivationIfNeeded(evalScope) + +internal suspend fun <A> NodeConnection<A>.removeDownstream(downstream: Schedulable) = + schedulerUpstream.removeDownstream(downstream) + +internal suspend fun <A> NodeConnection<A>.getPushEvent(evalScope: EvalScope): Maybe<A> = + directUpstream.getPushEvent(evalScope) + +internal val <A> NodeConnection<A>.depthTracker: DepthTracker + get() = schedulerUpstream.depthTracker diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TStateImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TStateImpl.kt new file mode 100644 index 000000000000..d8b6dac000bb --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TStateImpl.kt @@ -0,0 +1,377 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.internal.util.associateByIndex +import com.android.systemui.experimental.frp.internal.util.hashString +import com.android.systemui.experimental.frp.internal.util.mapValuesParallel +import com.android.systemui.experimental.frp.util.Just +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.none +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi + +internal sealed interface TStateImpl<out A> { + val name: String? + val operatorName: String + val changes: TFlowImpl<A> + + suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long> +} + +internal sealed class TStateDerived<A>(override val changes: TFlowImpl<A>) : + TStateImpl<A>, Key<Deferred<Pair<A, Long>>> { + + @Volatile + var invalidatedEpoch = Long.MIN_VALUE + private set + + @Volatile + protected var cache: Any? = EmptyCache + private set + + override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long> = + evalScope.transactionStore + .getOrPut(this) { evalScope.deferAsync(CoroutineStart.LAZY) { pull(evalScope) } } + .await() + + suspend fun pull(evalScope: EvalScope): Pair<A, Long> { + @Suppress("UNCHECKED_CAST") + return recalc(evalScope)?.also { (a, epoch) -> setCache(a, epoch) } + ?: ((cache as A) to invalidatedEpoch) + } + + fun setCache(value: A, epoch: Long) { + if (epoch > invalidatedEpoch) { + cache = value + invalidatedEpoch = epoch + } + } + + fun getCachedUnsafe(): Maybe<A> { + @Suppress("UNCHECKED_CAST") + return if (cache == EmptyCache) none else just(cache as A) + } + + protected abstract suspend fun recalc(evalScope: EvalScope): Pair<A, Long>? + + private data object EmptyCache +} + +internal class TStateSource<A>( + override val name: String?, + override val operatorName: String, + init: Deferred<A>, + override val changes: TFlowImpl<A>, +) : TStateImpl<A> { + constructor( + name: String?, + operatorName: String, + init: A, + changes: TFlowImpl<A>, + ) : this(name, operatorName, CompletableDeferred(init), changes) + + lateinit var upstreamConnection: NodeConnection<A> + + // Note: Don't need to synchronize; we will never interleave reads and writes, since all writes + // are performed at the end of a network step, after any reads would have taken place. + + @Volatile private var _current: Deferred<A> = init + @Volatile + var writeEpoch = 0L + private set + + override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long> = + _current.await() to writeEpoch + + /** called by network after eval phase has completed */ + suspend fun updateState(evalScope: EvalScope) { + // write the latch + val eventResult = upstreamConnection.getPushEvent(evalScope) + if (eventResult is Just) { + _current = CompletableDeferred(eventResult.value) + writeEpoch = evalScope.epoch + } + } + + override fun toString(): String = "TStateImpl(changes=$changes, current=$_current)" + + @OptIn(ExperimentalCoroutinesApi::class) + fun getStorageUnsafe(): Maybe<A> = + if (_current.isCompleted) just(_current.getCompleted()) else none +} + +internal fun <A> constS(name: String?, operatorName: String, init: A): TStateImpl<A> = + TStateSource(name, operatorName, init, neverImpl) + +internal inline fun <A> mkState( + name: String?, + operatorName: String, + evalScope: EvalScope, + crossinline getChanges: suspend EvalScope.() -> TFlowImpl<A>, + init: Deferred<A>, +): TStateImpl<A> { + lateinit var state: TStateSource<A> + val calm: TFlowImpl<A> = + filterNode(getChanges) { new -> new != state.getCurrentWithEpoch(evalScope = this).first } + .cached() + return TStateSource(name, operatorName, init, calm).also { + state = it + evalScope.scheduleOutput( + OneShot { + calm.activate(evalScope = this, downstream = Schedulable.S(state))?.let { + (connection, needsEval) -> + state.upstreamConnection = connection + if (needsEval) { + schedule(state) + } + } + } + ) + } +} + +private inline fun <A> TFlowImpl<A>.calm( + crossinline getState: () -> TStateDerived<A> +): TFlowImpl<A> = + filterNode({ this@calm }) { new -> + val state = getState() + val (current, _) = state.getCurrentWithEpoch(evalScope = this) + if (new != current) { + state.setCache(new, epoch) + true + } else { + false + } + } + .cached() + +internal fun <A, B> TStateImpl<A>.mapCheap( + name: String?, + operatorName: String, + transform: suspend EvalScope.(A) -> B, +): TStateImpl<B> = + DerivedMapCheap(name, operatorName, this, mapImpl({ changes }) { transform(it) }, transform) + +internal class DerivedMapCheap<A, B>( + override val name: String?, + override val operatorName: String, + val upstream: TStateImpl<A>, + override val changes: TFlowImpl<B>, + private val transform: suspend EvalScope.(A) -> B, +) : TStateImpl<B> { + + override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<B, Long> { + val (a, epoch) = upstream.getCurrentWithEpoch(evalScope) + return evalScope.transform(a) to epoch + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +internal fun <A, B> TStateImpl<A>.map( + name: String?, + operatorName: String, + transform: suspend EvalScope.(A) -> B, +): TStateImpl<B> { + lateinit var state: TStateDerived<B> + val mappedChanges = mapImpl({ changes }) { transform(it) }.cached().calm { state } + state = DerivedMap(name, operatorName, transform, this, mappedChanges) + return state +} + +internal class DerivedMap<A, B>( + override val name: String?, + override val operatorName: String, + private val transform: suspend EvalScope.(A) -> B, + val upstream: TStateImpl<A>, + changes: TFlowImpl<B>, +) : TStateDerived<B>(changes) { + override fun toString(): String = "${this::class.simpleName}@$hashString" + + override suspend fun recalc(evalScope: EvalScope): Pair<B, Long>? { + val (a, epoch) = upstream.getCurrentWithEpoch(evalScope) + return if (epoch > invalidatedEpoch) { + evalScope.transform(a) to epoch + } else { + null + } + } +} + +internal fun <A> TStateImpl<TStateImpl<A>>.flatten(name: String?, operator: String): TStateImpl<A> { + // emits the current value of the new inner state, when that state is emitted + val switchEvents = mapImpl({ changes }) { newInner -> newInner.getCurrentWithEpoch(this).first } + // emits the new value of the new inner state when that state is emitted, or + // falls back to the current value if a new state is *not* being emitted this + // transaction + val innerChanges = + mapImpl({ changes }) { newInner -> + mergeNodes({ switchEvents }, { newInner.changes }) { _, new -> new } + } + val switchedChanges: TFlowImpl<A> = + mapImpl({ + switchPromptImpl( + getStorage = { + mapOf(Unit to this@flatten.getCurrentWithEpoch(evalScope = this).first.changes) + }, + getPatches = { mapImpl({ innerChanges }) { new -> mapOf(Unit to just(new)) } }, + ) + }) { map -> + map.getValue(Unit) + } + lateinit var state: DerivedFlatten<A> + state = DerivedFlatten(name, operator, this, switchedChanges.calm { state }) + return state +} + +internal class DerivedFlatten<A>( + override val name: String?, + override val operatorName: String, + val upstream: TStateImpl<TStateImpl<A>>, + changes: TFlowImpl<A>, +) : TStateDerived<A>(changes) { + override suspend fun recalc(evalScope: EvalScope): Pair<A, Long> { + val (inner, epoch0) = upstream.getCurrentWithEpoch(evalScope) + val (a, epoch1) = inner.getCurrentWithEpoch(evalScope) + return a to maxOf(epoch0, epoch1) + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun <A, B> TStateImpl<A>.flatMap( + name: String?, + operatorName: String, + noinline transform: suspend EvalScope.(A) -> TStateImpl<B>, +): TStateImpl<B> = map(null, operatorName, transform).flatten(name, operatorName) + +internal fun <A, B, Z> zipStates( + name: String?, + operatorName: String, + l1: TStateImpl<A>, + l2: TStateImpl<B>, + transform: suspend EvalScope.(A, B) -> Z, +): TStateImpl<Z> = + zipStates(null, operatorName, mapOf(0 to l1, 1 to l2)).map(name, operatorName) { + val a = it.getValue(0) + val b = it.getValue(1) + @Suppress("UNCHECKED_CAST") transform(a as A, b as B) + } + +internal fun <A, B, C, Z> zipStates( + name: String?, + operatorName: String, + l1: TStateImpl<A>, + l2: TStateImpl<B>, + l3: TStateImpl<C>, + transform: suspend EvalScope.(A, B, C) -> Z, +): TStateImpl<Z> = + zipStates(null, operatorName, mapOf(0 to l1, 1 to l2, 2 to l3)).map(name, operatorName) { + val a = it.getValue(0) + val b = it.getValue(1) + val c = it.getValue(2) + @Suppress("UNCHECKED_CAST") transform(a as A, b as B, c as C) + } + +internal fun <A, B, C, D, Z> zipStates( + name: String?, + operatorName: String, + l1: TStateImpl<A>, + l2: TStateImpl<B>, + l3: TStateImpl<C>, + l4: TStateImpl<D>, + transform: suspend EvalScope.(A, B, C, D) -> Z, +): TStateImpl<Z> = + zipStates(null, operatorName, mapOf(0 to l1, 1 to l2, 2 to l3, 3 to l4)).map( + name, + operatorName, + ) { + val a = it.getValue(0) + val b = it.getValue(1) + val c = it.getValue(2) + val d = it.getValue(3) + @Suppress("UNCHECKED_CAST") transform(a as A, b as B, c as C, d as D) + } + +internal fun <K : Any, A> zipStates( + name: String?, + operatorName: String, + states: Map<K, TStateImpl<A>>, +): TStateImpl<Map<K, A>> { + if (states.isEmpty()) return constS(name, operatorName, emptyMap()) + val stateChanges: Map<K, TFlowImpl<A>> = states.mapValues { it.value.changes } + lateinit var state: DerivedZipped<K, A> + // No need for calm; invariant ensures that changes will only emit when there's a difference + val changes: TFlowImpl<Map<K, A>> = + mapImpl({ + switchDeferredImpl(getStorage = { stateChanges }, getPatches = { neverImpl }) + }) { patch -> + states + .mapValues { (k, v) -> + if (k in patch) { + patch.getValue(k) + } else { + v.getCurrentWithEpoch(evalScope = this).first + } + } + .also { state.setCache(it, epoch) } + } + state = DerivedZipped(name, operatorName, states, changes) + return state +} + +internal class DerivedZipped<K : Any, A>( + override val name: String?, + override val operatorName: String, + val upstream: Map<K, TStateImpl<A>>, + changes: TFlowImpl<Map<K, A>>, +) : TStateDerived<Map<K, A>>(changes) { + override suspend fun recalc(evalScope: EvalScope): Pair<Map<K, A>, Long> { + val newEpoch = AtomicLong() + return upstream.mapValuesParallel { + val (a, epoch) = it.value.getCurrentWithEpoch(evalScope) + newEpoch.accumulateAndGet(epoch, ::maxOf) + a + } to newEpoch.get() + } + + override fun toString(): String = "${this::class.simpleName}@$hashString" +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun <A> zipStates( + name: String?, + operatorName: String, + states: List<TStateImpl<A>>, +): TStateImpl<List<A>> = + if (states.isEmpty()) { + constS(name, operatorName, emptyList()) + } else { + zipStates(null, operatorName, states.asIterable().associateByIndex()).mapCheap( + name, + operatorName, + ) { + it.values.toList() + } + } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TransactionalImpl.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TransactionalImpl.kt new file mode 100644 index 000000000000..c3f80a179684 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/TransactionalImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal + +import com.android.systemui.experimental.frp.internal.util.Key +import com.android.systemui.experimental.frp.internal.util.hashString +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred + +internal sealed class TransactionalImpl<out A> { + data class Const<out A>(val value: Deferred<A>) : TransactionalImpl<A>() + + class Impl<A>(val block: suspend EvalScope.() -> A) : TransactionalImpl<A>(), Key<Deferred<A>> { + override fun toString(): String = "${this::class.simpleName}@$hashString" + } +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun <A> transactionalImpl( + noinline block: suspend EvalScope.() -> A +): TransactionalImpl<A> = TransactionalImpl.Impl(block) + +internal fun <A> TransactionalImpl<A>.sample(evalScope: EvalScope): Deferred<A> = + when (this) { + is TransactionalImpl.Const -> value + is TransactionalImpl.Impl -> + evalScope.transactionStore + .getOrPut(this) { + evalScope.deferAsync(start = CoroutineStart.LAZY) { evalScope.block() } + } + .also { it.start() } + } diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Bag.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Bag.kt new file mode 100644 index 000000000000..cc5538e5c87c --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Bag.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal.util + +internal class Bag<T> private constructor(private val intMap: MutableMap<T, Int>) : + Set<T> by intMap.keys { + + constructor() : this(hashMapOf()) + + override fun toString(): String = intMap.toString() + + fun add(element: T): Boolean { + val entry = intMap[element] + return if (entry != null) { + intMap[element] = entry + 1 + false + } else { + intMap[element] = 1 + true + } + } + + fun remove(element: T): Boolean { + val entry = intMap[element] + return when { + entry == null -> { + false + } + entry <= 1 -> { + intMap.remove(element) + true + } + else -> { + intMap[element] = entry - 1 + false + } + } + } + + fun addAll(elements: Iterable<T>, butNot: T? = null): Set<T>? { + val newlyAdded = hashSetOf<T>() + for (value in elements) { + if (value != butNot) { + if (add(value)) { + newlyAdded.add(value) + } + } + } + return newlyAdded.ifEmpty { null } + } + + fun clear() { + intMap.clear() + } + + fun removeAll(elements: Collection<T>): Set<T>? { + val result = hashSetOf<T>() + for (element in elements) { + if (remove(element)) { + result.add(element) + } + } + return result.ifEmpty { null } + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/ConcurrentNullableHashMap.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/ConcurrentNullableHashMap.kt new file mode 100644 index 000000000000..449aa19be635 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/ConcurrentNullableHashMap.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal.util + +import java.util.concurrent.ConcurrentHashMap + +internal class ConcurrentNullableHashMap<K : Any, V> +private constructor(private val inner: ConcurrentHashMap<K, Any>) { + constructor() : this(ConcurrentHashMap()) + + @Suppress("UNCHECKED_CAST") + operator fun get(key: K): V? = inner[key]?.takeIf { it !== NullValue } as V? + + @Suppress("UNCHECKED_CAST") + fun put(key: K, value: V?): V? = + inner.put(key, value ?: NullValue)?.takeIf { it !== NullValue } as V? + + operator fun set(key: K, value: V?) { + put(key, value) + } + + @Suppress("UNCHECKED_CAST") + fun toMap(): Map<K, V> = inner.mapValues { (_, v) -> v.takeIf { it !== NullValue } as V } + + fun clear() { + inner.clear() + } + + fun isNotEmpty(): Boolean = inner.isNotEmpty() +} + +private object NullValue diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/HeteroMap.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/HeteroMap.kt new file mode 100644 index 000000000000..14a567ca67c8 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/HeteroMap.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal.util + +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.just +import java.util.concurrent.ConcurrentHashMap + +internal interface Key<A> + +private object NULL + +internal class HeteroMap { + + private val store = ConcurrentHashMap<Key<*>, Any>() + + @Suppress("UNCHECKED_CAST") + operator fun <A> get(key: Key<A>): Maybe<A> = + store[key]?.let { just((if (it === NULL) null else it) as A) } ?: None + + operator fun <A> set(key: Key<A>, value: A) { + store[key] = value ?: NULL + } + + operator fun contains(key: Key<*>): Boolean = store.containsKey(key) + + fun clear() { + store.clear() + } + + @Suppress("UNCHECKED_CAST") + fun <A> remove(key: Key<A>): Maybe<A> = + store.remove(key)?.let { just((if (it === NULL) null else it) as A) } ?: None + + @Suppress("UNCHECKED_CAST") + fun <A> getOrPut(key: Key<A>, defaultValue: () -> A): A = + store.compute(key) { _, value -> value ?: defaultValue() ?: NULL } as A +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/MapUtils.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/MapUtils.kt new file mode 100644 index 000000000000..6f19a76f8900 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/MapUtils.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.internal.util + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield + +// TODO: It's possible that this is less efficient than having each coroutine directly insert into a +// ConcurrentHashMap, but then we would lose ordering +internal suspend inline fun <K, A, B : Any, M : MutableMap<K, B>> Map<K, A> + .mapValuesNotNullParallelTo( + destination: M, + crossinline block: suspend (Map.Entry<K, A>) -> B?, +): M = + destination.also { + coroutineScope { + mapValues { + async { + yield() + block(it) + } + } + } + .mapValuesNotNullTo(it) { (_, deferred) -> deferred.await() } + } + +internal inline fun <K, A, B : Any, M : MutableMap<K, B>> Map<K, A>.mapValuesNotNullTo( + destination: M, + block: (Map.Entry<K, A>) -> B?, +): M = + destination.also { + for (entry in this@mapValuesNotNullTo) { + block(entry)?.let { destination.put(entry.key, it) } + } + } + +internal suspend fun <A, B> Iterable<A>.mapParallel(transform: suspend (A) -> B): List<B> = + coroutineScope { + map { async(start = CoroutineStart.LAZY) { transform(it) } }.awaitAll() + } + +internal suspend fun <K, A, B, M : MutableMap<K, B>> Map<K, A>.mapValuesParallelTo( + destination: M, + transform: suspend (Map.Entry<K, A>) -> B, +): Map<K, B> = entries.mapParallel { it.key to transform(it) }.toMap(destination) + +internal suspend fun <K, A, B> Map<K, A>.mapValuesParallel( + transform: suspend (Map.Entry<K, A>) -> B +): Map<K, B> = mapValuesParallelTo(mutableMapOf(), transform) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Util.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Util.kt new file mode 100644 index 000000000000..0a47429d4113 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/internal/util/Util.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.experimental.frp.internal.util + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.newCoroutineContext + +internal fun <A> CoroutineScope.asyncImmediate( + start: CoroutineStart = CoroutineStart.UNDISPATCHED, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> A, +): Deferred<A> = async(start = start, context = Dispatchers.Unconfined + context, block = block) + +internal fun CoroutineScope.launchImmediate( + start: CoroutineStart = CoroutineStart.UNDISPATCHED, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit, +): Job = launch(start = start, context = Dispatchers.Unconfined + context, block = block) + +internal suspend fun awaitCancellationAndThen(block: suspend () -> Unit) { + try { + awaitCancellation() + } finally { + block() + } +} + +internal fun CoroutineScope.launchOnCancel( + context: CoroutineContext = EmptyCoroutineContext, + block: () -> Unit, +): Job = + launch(context = context, start = CoroutineStart.UNDISPATCHED) { + awaitCancellationAndThen(block) + } + +internal fun CoroutineScope.childScope( + context: CoroutineContext = EmptyCoroutineContext +): CoroutineScope { + val newContext = newCoroutineContext(context) + val newJob = Job(parent = newContext[Job]) + return CoroutineScope(newContext + newJob) +} + +internal fun <A> Iterable<A>.associateByIndex(): Map<Int, A> = buildMap { + forEachIndexed { index, a -> put(index, a) } +} + +internal fun <A, M : MutableMap<Int, A>> Iterable<A>.associateByIndexTo(destination: M): M = + destination.apply { forEachIndexed { index, a -> put(index, a) } } + +internal val Any.hashString: String + get() = Integer.toHexString(System.identityHashCode(this)) diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Either.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Either.kt new file mode 100644 index 000000000000..dca8364ed8ef --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Either.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE") + +package com.android.systemui.experimental.frp.util + +/** + * Contains a value of two possibilities: `Left<A>` or `Right<B>` + * + * [Either] generalizes sealed classes the same way that [Pair] generalizes data classes; if a + * [Pair] is effectively an anonymous grouping of two instances, then an [Either] is an anonymous + * set of two options. + */ +sealed class Either<out A, out B> + +/** An [Either] that contains a [Left] value. */ +data class Left<out A>(val value: A) : Either<A, Nothing>() + +/** An [Either] that contains a [Right] value. */ +data class Right<out B>(val value: B) : Either<Nothing, B>() + +/** + * Returns an [Either] containing the result of applying [transform] to the [Left] value, or the + * [Right] value unchanged. + */ +inline fun <A, B, C> Either<A, C>.mapLeft(transform: (A) -> B): Either<B, C> = + when (this) { + is Left -> Left(transform(value)) + is Right -> this + } + +/** + * Returns an [Either] containing the result of applying [transform] to the [Right] value, or the + * [Left] value unchanged. + */ +inline fun <A, B, C> Either<A, B>.mapRight(transform: (B) -> C): Either<A, C> = + when (this) { + is Left -> this + is Right -> Right(transform(value)) + } + +/** Returns a [Maybe] containing the [Left] value held by this [Either], if present. */ +inline fun <A> Either<A, *>.leftMaybe(): Maybe<A> = + when (this) { + is Left -> just(value) + else -> None + } + +/** Returns the [Left] value held by this [Either], or `null` if this is a [Right] value. */ +inline fun <A> Either<A, *>.leftOrNull(): A? = + when (this) { + is Left -> value + else -> null + } + +/** Returns a [Maybe] containing the [Right] value held by this [Either], if present. */ +inline fun <B> Either<*, B>.rightMaybe(): Maybe<B> = + when (this) { + is Right -> just(value) + else -> None + } + +/** Returns the [Right] value held by this [Either], or `null` if this is a [Left] value. */ +inline fun <B> Either<*, B>.rightOrNull(): B? = + when (this) { + is Right -> value + else -> null + } + +/** + * Partitions this sequence of [Either] into two lists; [Pair.first] contains all [Left] values, and + * [Pair.second] contains all [Right] values. + */ +fun <A, B> Sequence<Either<A, B>>.partitionEithers(): Pair<List<A>, List<B>> { + val lefts = mutableListOf<A>() + val rights = mutableListOf<B>() + for (either in this) { + when (either) { + is Left -> lefts.add(either.value) + is Right -> rights.add(either.value) + } + } + return lefts to rights +} + +/** + * Partitions this map of [Either] values into two maps; [Pair.first] contains all [Left] values, + * and [Pair.second] contains all [Right] values. + */ +fun <K, A, B> Map<K, Either<A, B>>.partitionEithers(): Pair<Map<K, A>, Map<K, B>> { + val lefts = mutableMapOf<K, A>() + val rights = mutableMapOf<K, B>() + for ((k, e) in this) { + when (e) { + is Left -> lefts[k] = e.value + is Right -> rights[k] = e.value + } + } + return lefts to rights +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Maybe.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Maybe.kt new file mode 100644 index 000000000000..59c680e91a52 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/Maybe.kt @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE", "SuspendCoroutine") + +package com.android.systemui.experimental.frp.util + +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.RestrictsSuspension +import kotlin.coroutines.resume +import kotlin.coroutines.startCoroutine +import kotlin.coroutines.suspendCoroutine + +/** Represents a value that may or may not be present. */ +sealed class Maybe<out A> + +/** A [Maybe] value that is present. */ +data class Just<out A> internal constructor(val value: A) : Maybe<A>() + +/** A [Maybe] value that is not present. */ +data object None : Maybe<Nothing>() + +/** Utilities to query [Maybe] instances from within a [maybe] block. */ +@RestrictsSuspension +object MaybeScope { + suspend operator fun <A> Maybe<A>.not(): A = suspendCoroutine { k -> + if (this is Just) k.resume(value) + } + + suspend inline fun guard(crossinline block: () -> Boolean): Unit = suspendCoroutine { k -> + if (block()) k.resume(Unit) + } +} + +/** + * Returns a [Maybe] value produced by evaluating [block]. + * + * [block] can use its [MaybeScope] receiver to query other [Maybe] values, automatically cancelling + * execution of [block] and producing [None] when attempting to query a [Maybe] that is not present. + * + * This can be used instead of Kotlin's built-in nullability (`?.` and `?:`) operators when dealing + * with complex combinations of nullables: + * ``` kotlin + * val aMaybe: Maybe<Any> = ... + * val bMaybe: Maybe<Any> = ... + * val result: String = maybe { + * val a = !aMaybe + * val b = !bMaybe + * "Got: $a and $b" + * } + * ``` + */ +fun <A> maybe(block: suspend MaybeScope.() -> A): Maybe<A> { + var maybeResult: Maybe<A> = None + val k = + object : Continuation<A> { + override val context: CoroutineContext = EmptyCoroutineContext + + override fun resumeWith(result: Result<A>) { + maybeResult = result.getOrNull()?.let { just(it) } ?: None + } + } + block.startCoroutine(MaybeScope, k) + return maybeResult +} + +/** Returns a [Just] containing this value, or [None] if `null`. */ +inline fun <A> (A?).toMaybe(): Maybe<A> = maybe(this) + +/** Returns a [Just] containing a non-null [value], or [None] if `null`. */ +inline fun <A> maybe(value: A?): Maybe<A> = value?.let(::just) ?: None + +/** Returns a [Just] containing [value]. */ +fun <A> just(value: A): Maybe<A> = Just(value) + +/** A [Maybe] that is not present. */ +val none: Maybe<Nothing> = None + +/** A [Maybe] that is not present. */ +inline fun <A> none(): Maybe<A> = None + +/** Returns the value present in this [Maybe], or `null` if not present. */ +inline fun <A> Maybe<A>.orNull(): A? = orElse(null) + +/** + * Returns a [Maybe] holding the result of applying [transform] to the value in the original + * [Maybe]. + */ +inline fun <A, B> Maybe<A>.map(transform: (A) -> B): Maybe<B> = + when (this) { + is Just -> just(transform(value)) + is None -> None + } + +/** Returns the result of applying [transform] to the value in the original [Maybe]. */ +inline fun <A, B> Maybe<A>.flatMap(transform: (A) -> Maybe<B>): Maybe<B> = + when (this) { + is Just -> transform(value) + is None -> None + } + +/** Returns the value present in this [Maybe], or the result of [defaultValue] if not present. */ +inline fun <A> Maybe<A>.orElseGet(defaultValue: () -> A): A = + when (this) { + is Just -> value + is None -> defaultValue() + } + +/** + * Returns the value present in this [Maybe], or invokes [error] with the message returned from + * [getMessage]. + */ +inline fun <A> Maybe<A>.orError(getMessage: () -> Any): A = orElseGet { error(getMessage()) } + +/** Returns the value present in this [Maybe], or [defaultValue] if not present. */ +inline fun <A> Maybe<A>.orElse(defaultValue: A): A = + when (this) { + is Just -> value + is None -> defaultValue + } + +/** + * Returns a [Maybe] that contains the present in the original [Maybe], only if it satisfies + * [predicate]. + */ +inline fun <A> Maybe<A>.filter(predicate: (A) -> Boolean): Maybe<A> = + when (this) { + is Just -> if (predicate(value)) this else None + else -> this + } + +/** Returns a [List] containing all values that are present in this [Iterable]. */ +fun <A> Iterable<Maybe<A>>.filterJust(): List<A> = asSequence().filterJust().toList() + +/** Returns a [List] containing all values that are present in this [Sequence]. */ +fun <A> Sequence<Maybe<A>>.filterJust(): Sequence<A> = filterIsInstance<Just<A>>().map { it.value } + +// Align + +/** + * Returns a [Maybe] containing the result of applying the values present in the original [Maybe] + * and other, applied to [transform] as a [These]. + */ +inline fun <A, B, C> Maybe<A>.alignWith(other: Maybe<B>, transform: (These<A, B>) -> C): Maybe<C> = + when (this) { + is Just -> { + val a = value + when (other) { + is Just -> { + val b = other.value + just(transform(These.both(a, b))) + } + None -> just(transform(These.thiz(a))) + } + } + None -> + when (other) { + is Just -> { + val b = other.value + just(transform(These.that(b))) + } + None -> none + } + } + +// Alt + +/** Returns a [Maybe] containing the value present in the original [Maybe], or [other]. */ +infix fun <A> Maybe<A>.orElseMaybe(other: Maybe<A>): Maybe<A> = orElseGetMaybe { other } + +/** + * Returns a [Maybe] containing the value present in the original [Maybe], or the result of [other]. + */ +inline fun <A> Maybe<A>.orElseGetMaybe(other: () -> Maybe<A>): Maybe<A> = + when (this) { + is Just -> this + else -> other() + } + +// Apply + +/** + * Returns a [Maybe] containing the value present in [argMaybe] applied to the function present in + * the original [Maybe]. + */ +fun <A, B> Maybe<(A) -> B>.apply(argMaybe: Maybe<A>): Maybe<B> = flatMap { f -> + argMaybe.map { a -> f(a) } +} + +/** + * Returns a [Maybe] containing the result of applying [transform] to the values present in the + * original [Maybe] and [other]. + */ +inline fun <A, B, C> Maybe<A>.zipWith(other: Maybe<B>, transform: (A, B) -> C) = flatMap { a -> + other.map { b -> transform(a, b) } +} + +// Bind + +/** + * Returns a [Maybe] containing the value present in the [Maybe] present in the original [Maybe]. + */ +fun <A> Maybe<Maybe<A>>.flatten(): Maybe<A> = flatMap { it } + +// Semigroup + +/** + * Returns a [Maybe] containing the result of applying the values present in the original [Maybe] + * and other, applied to [transform]. + */ +fun <A> Maybe<A>.mergeWith(other: Maybe<A>, transform: (A, A) -> A): Maybe<A> = + alignWith(other) { it.merge(transform) } + +/** + * Returns a list containing only the present results of applying [transform] to each element in the + * original iterable. + */ +fun <A, B> Iterable<A>.mapMaybe(transform: (A) -> Maybe<B>): List<B> = + asSequence().mapMaybe(transform).toList() + +/** + * Returns a sequence containing only the present results of applying [transform] to each element in + * the original sequence. + */ +fun <A, B> Sequence<A>.mapMaybe(transform: (A) -> Maybe<B>): Sequence<B> = + map(transform).filterIsInstance<Just<B>>().map { it.value } + +/** + * Returns a map with values of only the present results of applying [transform] to each entry in + * the original map. + */ +inline fun <K, A, B> Map<K, A>.mapMaybeValues( + crossinline p: (Map.Entry<K, A>) -> Maybe<B> +): Map<K, B> = asSequence().mapMaybe { entry -> p(entry).map { entry.key to it } }.toMap() + +/** Returns a map with all non-present values filtered out. */ +fun <K, A> Map<K, Maybe<A>>.filterJustValues(): Map<K, A> = + asSequence().mapMaybe { (key, mValue) -> mValue.map { key to it } }.toMap() + +/** + * Returns a pair of [Maybes][Maybe] that contain the [Pair.first] and [Pair.second] values present + * in the original [Maybe]. + */ +fun <A, B> Maybe<Pair<A, B>>.splitPair(): Pair<Maybe<A>, Maybe<B>> = + map { it.first } to map { it.second } + +/** Returns the value associated with [key] in this map as a [Maybe]. */ +fun <K, V> Map<K, V>.getMaybe(key: K): Maybe<V> { + val value = get(key) + if (value == null && !containsKey(key)) { + return none + } else { + @Suppress("UNCHECKED_CAST") + return just(value as V) + } +} diff --git a/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/These.kt b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/These.kt new file mode 100644 index 000000000000..5404c0795af8 --- /dev/null +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/These.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.experimental.frp.util + +/** Contains at least one of two potential values. */ +sealed class These<A, B> { + /** Contains a single potential value. */ + class This<A, B> internal constructor(val thiz: A) : These<A, B>() + + /** Contains a single potential value. */ + class That<A, B> internal constructor(val that: B) : These<A, B>() + + /** Contains both potential values. */ + class Both<A, B> internal constructor(val thiz: A, val that: B) : These<A, B>() + + companion object { + /** Constructs a [These] containing only [thiz]. */ + fun <A, B> thiz(thiz: A): These<A, B> = This(thiz) + + /** Constructs a [These] containing only [that]. */ + fun <A, B> that(that: B): These<A, B> = That(that) + + /** Constructs a [These] containing both [thiz] and [that]. */ + fun <A, B> both(thiz: A, that: B): These<A, B> = Both(thiz, that) + } +} + +/** + * Returns a single value from this [These]; either the single value held within, or the result of + * applying [f] to both values. + */ +inline fun <A> These<A, A>.merge(f: (A, A) -> A): A = + when (this) { + is These.This -> thiz + is These.That -> that + is These.Both -> f(thiz, that) + } + +/** Returns the [These.This] [value][These.This.thiz] present in this [These] as a [Maybe]. */ +fun <A> These<A, *>.maybeThis(): Maybe<A> = + when (this) { + is These.Both -> just(thiz) + is These.That -> None + is These.This -> just(thiz) + } + +/** + * Returns the [These.This] [value][These.This.thiz] present in this [These], or `null` if not + * present. + */ +fun <A : Any> These<A, *>.thisOrNull(): A? = + when (this) { + is These.Both -> thiz + is These.That -> null + is These.This -> thiz + } + +/** Returns the [These.That] [value][These.That.that] present in this [These] as a [Maybe]. */ +fun <A> These<*, A>.maybeThat(): Maybe<A> = + when (this) { + is These.Both -> just(that) + is These.That -> just(that) + is These.This -> None + } + +/** + * Returns the [These.That] [value][These.That.that] present in this [These], or `null` if not + * present. + */ +fun <A : Any> These<*, A>.thatOrNull(): A? = + when (this) { + is These.Both -> that + is These.That -> that + is These.This -> null + } + +/** Returns [These.Both] values present in this [These] as a [Maybe]. */ +fun <A, B> These<A, B>.maybeBoth(): Maybe<Pair<A, B>> = + when (this) { + is These.Both -> just(thiz to that) + else -> None + } + +/** Returns a [These] containing [thiz] and/or [that] if they are present. */ +fun <A, B> these(thiz: Maybe<A>, that: Maybe<B>): Maybe<These<A, B>> = + when (thiz) { + is Just -> + just( + when (that) { + is Just -> These.both(thiz.value, that.value) + else -> These.thiz(thiz.value) + } + ) + else -> + when (that) { + is Just -> just(These.that(that.value)) + else -> none + } + } + +/** + * Returns a [These] containing [thiz] and/or [that] if they are non-null, or `null` if both are + * `null`. + */ +fun <A : Any, B : Any> theseNull(thiz: A?, that: B?): These<A, B>? = + thiz?.let { that?.let { These.both(thiz, that) } ?: These.thiz(thiz) } + ?: that?.let { These.that(that) } + +/** + * Returns two maps, with [Pair.first] containing all [These.This] values and [Pair.second] + * containing all [These.That] values. + * + * If the value is [These.Both], then the associated key with appear in both output maps, bound to + * [These.Both.thiz] and [These.Both.that] in each respective output. + */ +fun <K, A, B> Map<K, These<A, B>>.partitionThese(): Pair<Map<K, A>, Map<K, B>> { + val a = mutableMapOf<K, A>() + val b = mutableMapOf<K, B>() + for ((k, t) in this) { + when (t) { + is These.Both -> { + a[k] = t.thiz + b[k] = t.that + } + is These.That -> { + b[k] = t.that + } + is These.This -> { + a[k] = t.thiz + } + } + } + return a to b +} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRedirect.java b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/WithPrev.kt index b582ccf7b656..e52a6e166ec5 100644 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRedirect.java +++ b/packages/SystemUI/frp/src/com/android/systemui/experimental/frp/util/WithPrev.kt @@ -13,23 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package android.ravenwood.annotation; -import static java.lang.annotation.ElementType.METHOD; +package com.android.systemui.experimental.frp.util -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target({METHOD}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodRedirect { -} +/** Holds a [newValue] emitted from a `TFlow`, along with the [previousValue] emitted value. */ +data class WithPrev<out S, out T : S>(val previousValue: S, val newValue: T) diff --git a/packages/SystemUI/frp/test/com/android/systemui/experimental/frp/FrpTests.kt b/packages/SystemUI/frp/test/com/android/systemui/experimental/frp/FrpTests.kt new file mode 100644 index 000000000000..a58f4995138a --- /dev/null +++ b/packages/SystemUI/frp/test/com/android/systemui/experimental/frp/FrpTests.kt @@ -0,0 +1,1370 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalFrpApi::class) + +package com.android.systemui.experimental.frp + +import com.android.systemui.experimental.frp.util.Either +import com.android.systemui.experimental.frp.util.Left +import com.android.systemui.experimental.frp.util.Maybe +import com.android.systemui.experimental.frp.util.None +import com.android.systemui.experimental.frp.util.Right +import com.android.systemui.experimental.frp.util.just +import com.android.systemui.experimental.frp.util.map +import com.android.systemui.experimental.frp.util.maybe +import com.android.systemui.experimental.frp.util.none +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.measureTime +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FrpTests { + + @Test + fun basic() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + var result: Int? = null + activateSpec(network) { emitter.observe { result = it } } + runCurrent() + emitter.emit(3) + runCurrent() + assertEquals(3, result) + runCurrent() + } + + @Test + fun basicTFlow() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + println("starting network") + val result = activateSpecWithResult(network) { emitter.nextDeferred() } + runCurrent() + println("emitting") + emitter.emit(3) + runCurrent() + println("awaiting") + assertEquals(3, result.await()) + runCurrent() + } + + @Test + fun basicTState() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + val result = activateSpecWithResult(network) { emitter.hold(0).stateChanges.nextDeferred() } + runCurrent() + + emitter.emit(3) + runCurrent() + + assertEquals(3, result.await()) + } + + @Test + fun basicEvent() = runFrpTest { network -> + val emitter = MutableSharedFlow<Int>() + val result = activateSpecWithResult(network) { async { emitter.first() } } + runCurrent() + emitter.emit(1) + runCurrent() + assertTrue("Result eventual has not completed.", result.isCompleted) + assertEquals(1, result.await()) + } + + @Test + fun basicTransactional() = runFrpTest { network -> + var value: Int? = null + var bSource = 1 + val emitter = network.mutableTFlow<Unit>() + // Sampling this transactional will increment the source count. + val transactional = transactionally { bSource++ } + measureTime { + activateSpecWithResult(network) { + // Two different flows that sample the same transactional. + (0 until 2).map { + val sampled = emitter.sample(transactional) { _, v -> v } + sampled.toSharedFlow() + } + } + .forEach { backgroundScope.launch { it.collect { value = it } } } + runCurrent() + } + .also { println("setup: ${it.toString(DurationUnit.MILLISECONDS, 2)}") } + + measureTime { + emitter.emit(Unit) + runCurrent() + } + .also { println("emit 1: ${it.toString(DurationUnit.MILLISECONDS, 2)}") } + + // Even though the transactional would be sampled twice, the first result is cached. + assertEquals(2, bSource) + assertEquals(1, value) + + measureTime { + bSource = 10 + emitter.emit(Unit) + runCurrent() + } + .also { println("emit 2: ${it.toString(DurationUnit.MILLISECONDS, 2)}") } + + assertEquals(11, bSource) + assertEquals(10, value) + } + + @Test + fun diamondGraph() = runFrpTest { network -> + val flow = network.mutableTFlow<Int>() + val outFlow = + activateSpecWithResult(network) { + // map TFlow like we map Flow + val left = flow.map { "left" to it }.onEach { println("left: $it") } + val right = flow.map { "right" to it }.onEach { println("right: $it") } + + // convert TFlows to TStates so that they can be combined + val combined = + left.hold("left" to 0).combineWith(right.hold("right" to 0)) { l, r -> l to r } + combined.stateChanges // get TState changes + .onEach { println("merged: $it") } + .toSharedFlow() // convert back to Flow + } + runCurrent() + + val results = mutableListOf<Pair<Pair<String, Int>, Pair<String, Int>>>() + backgroundScope.launch { outFlow.toCollection(results) } + runCurrent() + + flow.emit(1) + runCurrent() + + flow.emit(2) + runCurrent() + + assertEquals( + listOf(("left" to 1) to ("right" to 1), ("left" to 2) to ("right" to 2)), + results, + ) + } + + @Test + fun staticNetwork() = runFrpTest { network -> + var finalSum: Int? = null + + val intEmitter = network.mutableTFlow<Int>() + val sampleEmitter = network.mutableTFlow<Unit>() + + activateSpecWithResult(network) { + val updates = intEmitter.map { a -> { b: Int -> a + b } } + + val sumD = + TStateLoop<Int>().apply { + loopback = + updates + .sample(this) { f, sum -> f(sum) } + .onEach { println("sum update: $it") } + .hold(0) + } + sampleEmitter + .onEach { println("sampleEmitter emitted") } + .sample(sumD) { _, sum -> sum } + .onEach { println("sampled: $it") } + .nextDeferred() + } + .let { launch { finalSum = it.await() } } + + runCurrent() + + (1..5).forEach { i -> + println("emitting: $i") + intEmitter.emit(i) + runCurrent() + } + runCurrent() + + sampleEmitter.emit(Unit) + runCurrent() + + assertEquals(15, finalSum) + } + + @Test + fun recursiveDefinition() = runFrpTest { network -> + var wasSold = false + var currentAmt: Int? = null + + val coin = network.mutableTFlow<Unit>() + val price = 50 + val frpSpec = frpSpec { + val eSold = TFlowLoop<Unit>() + + val eInsert = + coin.map { + { runningTotal: Int -> + println("TEST: $runningTotal - 10 = ${runningTotal - 10}") + runningTotal - 10 + } + } + + val eReset = + eSold.map { + { _: Int -> + println("TEST: Resetting") + price + } + } + + val eUpdate = eInsert.mergeWith(eReset) { f, g -> { a -> g(f(a)) } } + + val dTotal = TStateLoop<Int>() + dTotal.loopback = eUpdate.sample(dTotal) { f, total -> f(total) }.hold(price) + + val eAmt = dTotal.stateChanges + val bAmt = transactionally { dTotal.sample() } + eSold.loopback = + coin + .sample(bAmt) { coin, total -> coin to total } + .mapMaybe { (_, total) -> maybe { guard { total <= 10 } } } + + val amts = eAmt.filter { amt -> amt >= 0 } + + amts.observe { currentAmt = it } + eSold.observe { wasSold = true } + + eSold.nextDeferred() + } + + activateSpec(network) { frpSpec.applySpec() } + + runCurrent() + + println() + println() + coin.emit(Unit) + runCurrent() + + assertEquals(40, currentAmt) + + println() + println() + coin.emit(Unit) + runCurrent() + + assertEquals(30, currentAmt) + + println() + println() + coin.emit(Unit) + runCurrent() + + assertEquals(20, currentAmt) + + println() + println() + coin.emit(Unit) + runCurrent() + + assertEquals(10, currentAmt) + assertEquals(false, wasSold) + + println() + println() + coin.emit(Unit) + runCurrent() + + assertEquals(true, wasSold) + assertEquals(50, currentAmt) + } + + @Test + fun promptCleanup() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + val stopper = network.mutableTFlow<Unit>() + + var result: Int? = null + + val flow = activateSpecWithResult(network) { emitter.takeUntil(stopper).toSharedFlow() } + backgroundScope.launch { flow.collect { result = it } } + runCurrent() + + emitter.emit(2) + runCurrent() + + assertEquals(2, result) + + stopper.emit(Unit) + runCurrent() + } + + @Test + fun switchTFlow() = runFrpTest { network -> + var currentSum: Int? = null + + val switchHandler = network.mutableTFlow<Pair<TFlow<Int>, String>>() + val aHandler = network.mutableTFlow<Int>() + val stopHandler = network.mutableTFlow<Unit>() + val bHandler = network.mutableTFlow<Int>() + + val sumFlow = + activateSpecWithResult(network) { + val switchE = TFlowLoop<TFlow<Int>>() + switchE.loopback = + switchHandler.mapStateful { (intFlow, name) -> + println("[onEach] Switching to: $name") + val nextSwitch = + switchE.skipNext().onEach { println("[onEach] switched-out") } + val stopEvent = + stopHandler + .onEach { println("[onEach] stopped") } + .mergeWith(nextSwitch) { _, b -> b } + intFlow.takeUntil(stopEvent) + } + + val adderE: TFlow<(Int) -> Int> = + switchE.hold(emptyTFlow).switch().map { a -> + println("[onEach] new number $a") + ({ sum: Int -> + println("$a+$sum=${a + sum}") + sum + a + }) + } + + val sumD = TStateLoop<Int>() + sumD.loopback = + adderE + .sample(sumD) { f, sum -> f(sum) } + .onEach { println("[onEach] writing sum: $it") } + .hold(0) + val sumE = sumD.stateChanges + + sumE.toSharedFlow() + } + + runCurrent() + + backgroundScope.launch { sumFlow.collect { currentSum = it } } + + runCurrent() + + switchHandler.emit(aHandler to "A") + runCurrent() + + aHandler.emit(1) + runCurrent() + + assertEquals(1, currentSum) + + aHandler.emit(2) + runCurrent() + + assertEquals(3, currentSum) + + aHandler.emit(3) + runCurrent() + + assertEquals(6, currentSum) + + aHandler.emit(4) + runCurrent() + + assertEquals(10, currentSum) + + aHandler.emit(5) + runCurrent() + + assertEquals(15, currentSum) + + switchHandler.emit(bHandler to "B") + runCurrent() + + aHandler.emit(6) + runCurrent() + + assertEquals(15, currentSum) + + bHandler.emit(6) + runCurrent() + + assertEquals(21, currentSum) + + bHandler.emit(7) + runCurrent() + + assertEquals(28, currentSum) + + bHandler.emit(8) + runCurrent() + + assertEquals(36, currentSum) + + bHandler.emit(9) + runCurrent() + + assertEquals(45, currentSum) + + bHandler.emit(10) + runCurrent() + + assertEquals(55, currentSum) + + println() + println("Stopping: B") + stopHandler.emit(Unit) // bHandler.complete() + runCurrent() + + bHandler.emit(20) + runCurrent() + + assertEquals(55, currentSum) + + println() + println("Switching to: A2") + switchHandler.emit(aHandler to "A2") + runCurrent() + + println("aHandler.emit(11)") + aHandler.emit(11) + runCurrent() + + assertEquals(66, currentSum) + + aHandler.emit(12) + runCurrent() + + assertEquals(78, currentSum) + + aHandler.emit(13) + runCurrent() + + assertEquals(91, currentSum) + + aHandler.emit(14) + runCurrent() + + assertEquals(105, currentSum) + + aHandler.emit(15) + runCurrent() + + assertEquals(120, currentSum) + + stopHandler.emit(Unit) + runCurrent() + + aHandler.emit(100) + runCurrent() + + assertEquals(120, currentSum) + } + + @Test + fun switchIndirect() = runFrpTest { network -> + val emitter = network.mutableTFlow<Unit>() + activateSpec(network) { + emptyTFlow.map { emitter.map { 1 } }.flatten().map { "$it" }.observe() + } + runCurrent() + } + + @Test + fun switchInWithResult() = runFrpTest { network -> + val emitter = network.mutableTFlow<Unit>() + val out = + activateSpecWithResult(network) { + emitter.map { emitter.map { 1 } }.flatten().toSharedFlow() + } + val result = out.stateIn(backgroundScope, SharingStarted.Eagerly, null) + runCurrent() + emitter.emit(Unit) + runCurrent() + assertEquals(null, result.value) + } + + @Test + fun switchInCompleted() = runFrpTest { network -> + val outputs = mutableListOf<Int>() + + val switchAH = network.mutableTFlow<Unit>() + val intAH = network.mutableTFlow<Int>() + val stopEmitter = network.mutableTFlow<Unit>() + + val top = frpSpec { + val intS = intAH.takeUntil(stopEmitter) + val switched = switchAH.map { intS }.flatten() + switched.toSharedFlow() + } + val flow = activateSpecWithResult(network) { top.applySpec() } + backgroundScope.launch { flow.collect { outputs.add(it) } } + runCurrent() + + switchAH.emit(Unit) + runCurrent() + + stopEmitter.emit(Unit) + runCurrent() + + // assertEquals(0, intAH.subscriptionCount.value) + intAH.emit(10) + runCurrent() + + assertEquals(true, outputs.isEmpty()) + + switchAH.emit(Unit) + runCurrent() + + // assertEquals(0, intAH.subscriptionCount.value) + intAH.emit(10) + runCurrent() + + assertEquals(true, outputs.isEmpty()) + } + + @Test + fun switchTFlow_outerCompletesFirst() = runFrpTest { network -> + var stepResult: Int? = null + + val switchAH = network.mutableTFlow<Unit>() + val switchStopEmitter = network.mutableTFlow<Unit>() + val intStopEmitter = network.mutableTFlow<Unit>() + val intAH = network.mutableTFlow<Int>() + val flow = + activateSpecWithResult(network) { + val intS = intAH.takeUntil(intStopEmitter) + val switchS = switchAH.takeUntil(switchStopEmitter) + + val switched = switchS.map { intS }.flatten() + switched.toSharedFlow() + } + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + + // assertEquals(0, intAH.subscriptionCount.value) + intAH.emit(100) + runCurrent() + + assertEquals(null, stepResult) + + switchAH.emit(Unit) + runCurrent() + + // assertEquals(1, intAH.subscriptionCount.value) + + intAH.emit(5) + runCurrent() + + assertEquals(5, stepResult) + + println("stop outer") + switchStopEmitter.emit(Unit) // switchAH.complete() + runCurrent() + + // assertEquals(1, intAH.subscriptionCount.value) + // assertEquals(0, switchAH.subscriptionCount.value) + + intAH.emit(10) + runCurrent() + + assertEquals(10, stepResult) + + println("stop inner") + intStopEmitter.emit(Unit) // intAH.complete() + runCurrent() + + // assertEquals(just(10), network.await()) + } + + @Test + fun mapTFlow() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + var stepResult: Int? = null + + val flow = + activateSpecWithResult(network) { + val mappedS = emitter.map { it * it } + mappedS.toSharedFlow() + } + + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + + emitter.emit(1) + runCurrent() + + assertEquals(1, stepResult) + + emitter.emit(2) + runCurrent() + + assertEquals(4, stepResult) + + emitter.emit(10) + runCurrent() + + assertEquals(100, stepResult) + } + + @Test + fun mapTransactional() = runFrpTest { network -> + var doubledResult: Int? = null + var pullValue = 0 + val a = transactionally { pullValue } + val b = transactionally { a.sample() * 2 } + val emitter = network.mutableTFlow<Unit>() + val flow = + activateSpecWithResult(network) { + val sampleB = emitter.sample(b) { _, b -> b } + sampleB.toSharedFlow() + } + + backgroundScope.launch { flow.collect { doubledResult = it } } + + runCurrent() + + emitter.emit(Unit) + runCurrent() + + assertEquals(0, doubledResult) + + pullValue = 5 + emitter.emit(Unit) + runCurrent() + + assertEquals(10, doubledResult) + } + + @Test + fun mapTState() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + var stepResult: Int? = null + val flow = + activateSpecWithResult(network) { + val state = emitter.hold(0).map { it + 2 } + val stateCurrent = transactionally { state.sample() } + val stateChanges = state.stateChanges + val sampleState = emitter.sample(stateCurrent) { _, b -> b } + val merge = stateChanges.mergeWith(sampleState) { a, b -> a + b } + merge.toSharedFlow() + } + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + + emitter.emit(1) + runCurrent() + + assertEquals(5, stepResult) + + emitter.emit(10) + runCurrent() + + assertEquals(15, stepResult) + } + + @Test + fun partitionEither() = runFrpTest { network -> + val emitter = network.mutableTFlow<Either<Int, Int>>() + val result = + activateSpecWithResult(network) { + val (l, r) = emitter.partitionEither() + val pDiamond = + l.map { it * 2 } + .mergeWith(r.map { it * -1 }) { _, _ -> error("unexpected coincidence") } + pDiamond.hold(null).toStateFlow() + } + runCurrent() + + emitter.emit(Left(10)) + runCurrent() + + assertEquals(20, result.value) + + emitter.emit(Right(30)) + runCurrent() + + assertEquals(-30, result.value) + } + + @Test + fun accumTState() = runFrpTest { network -> + val emitter = network.mutableTFlow<Int>() + val sampler = network.mutableTFlow<Unit>() + var stepResult: Int? = null + val flow = + activateSpecWithResult(network) { + val sumState = emitter.map { a -> { b: Int -> a + b } }.fold(0) { f, a -> f(a) } + + sumState.stateChanges + .mergeWith(sampler.sample(sumState) { _, sum -> sum }) { _, _ -> + error("Unexpected coincidence") + } + .toSharedFlow() + } + + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + + emitter.emit(5) + runCurrent() + assertEquals(5, stepResult) + + emitter.emit(10) + runCurrent() + assertEquals(15, stepResult) + + sampler.emit(Unit) + runCurrent() + assertEquals(15, stepResult) + } + + @Test + fun mergeTFlows() = runFrpTest { network -> + val first = network.mutableTFlow<Int>() + val stopFirst = network.mutableTFlow<Unit>() + val second = network.mutableTFlow<Int>() + val stopSecond = network.mutableTFlow<Unit>() + var stepResult: Int? = null + + val flow: SharedFlow<Int> + val setupDuration = measureTime { + flow = + activateSpecWithResult(network) { + val firstS = first.takeUntil(stopFirst) + val secondS = second.takeUntil(stopSecond) + val mergedS = + firstS.mergeWith(secondS) { _, _ -> error("Unexpected coincidence") } + mergedS.toSharedFlow() + // mergedS.last("onComplete") + } + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + } + + // assertEquals(1, first.subscriptionCount.value) + // assertEquals(1, second.subscriptionCount.value) + + val firstEmitDuration = measureTime { + first.emit(1) + runCurrent() + } + + assertEquals(1, stepResult) + + val secondEmitDuration = measureTime { + second.emit(2) + runCurrent() + } + + assertEquals(2, stepResult) + + val stopFirstDuration = measureTime { + stopFirst.emit(Unit) + runCurrent() + } + + // assertEquals(0, first.subscriptionCount.value) + val testDeadEmitFirstDuration = measureTime { + first.emit(10) + runCurrent() + } + + assertEquals(2, stepResult) + + // assertEquals(1, second.subscriptionCount.value) + + val secondEmitDuration2 = measureTime { + second.emit(3) + runCurrent() + } + + assertEquals(3, stepResult) + + val stopSecondDuration = measureTime { + stopSecond.emit(Unit) + runCurrent() + } + + // assertEquals(0, second.subscriptionCount.value) + val testDeadEmitSecondDuration = measureTime { + second.emit(10) + runCurrent() + } + + assertEquals(3, stepResult) + + println( + """ + setupDuration: ${setupDuration.toString(DurationUnit.MILLISECONDS, 2)} + firstEmitDuration: ${firstEmitDuration.toString(DurationUnit.MILLISECONDS, 2)} + secondEmitDuration: ${secondEmitDuration.toString(DurationUnit.MILLISECONDS, 2)} + stopFirstDuration: ${stopFirstDuration.toString(DurationUnit.MILLISECONDS, 2)} + testDeadEmitFirstDuration: ${ + testDeadEmitFirstDuration.toString( + DurationUnit.MILLISECONDS, + 2, + ) + } + secondEmitDuration2: ${secondEmitDuration2.toString(DurationUnit.MILLISECONDS, 2)} + stopSecondDuration: ${stopSecondDuration.toString(DurationUnit.MILLISECONDS, 2)} + testDeadEmitSecondDuration: ${ + testDeadEmitSecondDuration.toString( + DurationUnit.MILLISECONDS, + 2, + ) + } + """ + .trimIndent() + ) + } + + @Test + fun sampleCancel() = runFrpTest { network -> + val updater = network.mutableTFlow<Int>() + val stopUpdater = network.mutableTFlow<Unit>() + val sampler = network.mutableTFlow<Unit>() + val stopSampler = network.mutableTFlow<Unit>() + var stepResult: Int? = null + val flow = + activateSpecWithResult(network) { + val stopSamplerFirst = stopSampler + val samplerS = sampler.takeUntil(stopSamplerFirst) + val stopUpdaterFirst = stopUpdater + val updaterS = updater.takeUntil(stopUpdaterFirst) + val sampledS = samplerS.sample(updaterS.hold(0)) { _, b -> b } + sampledS.toSharedFlow() + } + + backgroundScope.launch { flow.collect { stepResult = it } } + runCurrent() + + updater.emit(1) + runCurrent() + + sampler.emit(Unit) + runCurrent() + + assertEquals(1, stepResult) + + stopSampler.emit(Unit) + runCurrent() + + // assertEquals(0, updater.subscriptionCount.value) + // assertEquals(0, sampler.subscriptionCount.value) + updater.emit(10) + runCurrent() + + sampler.emit(Unit) + runCurrent() + + assertEquals(1, stepResult) + } + + @Test + fun combineStates_differentUpstreams() = runFrpTest { network -> + val a = network.mutableTFlow<Int>() + val b = network.mutableTFlow<Int>() + var observed: Pair<Int, Int>? = null + val tState = + activateSpecWithResult(network) { + val state = combine(a.hold(0), b.hold(0)) { a, b -> Pair(a, b) } + state.stateChanges.observe { observed = it } + state + } + assertEquals(0 to 0, network.transact { tState.sample() }) + assertEquals(null, observed) + a.emit(5) + assertEquals(5 to 0, observed) + assertEquals(5 to 0, network.transact { tState.sample() }) + b.emit(3) + assertEquals(5 to 3, observed) + assertEquals(5 to 3, network.transact { tState.sample() }) + } + + @Test + fun sampleCombinedStates() = runFrpTest { network -> + val updater = network.mutableTFlow<Int>() + val emitter = network.mutableTFlow<Unit>() + + val result = + activateSpecWithResult(network) { + val bA = updater.map { it * 2 }.hold(0) + val bB = updater.hold(0) + val combineD: TState<Pair<Int, Int>> = bA.combineWith(bB) { a, b -> a to b } + val sampleS = emitter.sample(combineD) { _, b -> b } + sampleS.nextDeferred() + } + println("launching") + runCurrent() + + println("emitting update") + updater.emit(10) + runCurrent() + + println("emitting sampler") + emitter.emit(Unit) + runCurrent() + + println("asserting") + assertEquals(20 to 10, result.await()) + } + + @Test + fun switchMapPromptly() = runFrpTest { network -> + val emitter = network.mutableTFlow<Unit>() + val result = + activateSpecWithResult(network) { + emitter + .map { emitter.map { 1 }.map { it + 1 }.map { it * 2 } } + .hold(emptyTFlow) + .switchPromptly() + .nextDeferred() + } + runCurrent() + + emitter.emit(Unit) + runCurrent() + + assertTrue("Not complete", result.isCompleted) + assertEquals(4, result.await()) + } + + @Test + fun switchDeeper() = runFrpTest { network -> + val emitter = network.mutableTFlow<Unit>() + val e2 = network.mutableTFlow<Unit>() + val result = + activateSpecWithResult(network) { + val tres = + merge(e2.map { 1 }, e2.map { 2 }, transformCoincidence = { a, b -> a + b }) + tres.observeBuild() + val switch = emitter.map { tres }.flatten() + merge(switch, e2.map { null }, transformCoincidence = { a, _ -> a }) + .filterNotNull() + .nextDeferred() + } + runCurrent() + + emitter.emit(Unit) + runCurrent() + + e2.emit(Unit) + runCurrent() + + assertTrue("Not complete", result.isCompleted) + assertEquals(3, result.await()) + } + + @Test + fun recursionBasic() = runFrpTest { network -> + val add1 = network.mutableTFlow<Unit>() + val sub1 = network.mutableTFlow<Unit>() + val stepResult: StateFlow<Int> = + activateSpecWithResult(network) { + val dSum = TStateLoop<Int>() + val sAdd1 = add1.sample(dSum) { _, sum -> sum + 1 } + val sMinus1 = sub1.sample(dSum) { _, sum -> sum - 1 } + dSum.loopback = sAdd1.mergeWith(sMinus1) { a, _ -> a }.hold(0) + dSum.toStateFlow() + } + runCurrent() + + add1.emit(Unit) + runCurrent() + + assertEquals(1, stepResult.value) + + add1.emit(Unit) + runCurrent() + + assertEquals(2, stepResult.value) + + sub1.emit(Unit) + runCurrent() + + assertEquals(1, stepResult.value) + } + + @Test + fun recursiveTState() = runFrpTest { network -> + val e = network.mutableTFlow<Unit>() + var changes = 0 + val state = + activateSpecWithResult(network) { + val s = TFlowLoop<Unit>() + val deferred = s.map { tStateOf(null) } + val e3 = e.map { tStateOf(Unit) } + val flattened = e3.mergeWith(deferred) { a, _ -> a }.hold(tStateOf(null)).flatten() + s.loopback = emptyTFlow + flattened.toStateFlow() + } + + backgroundScope.launch { state.collect { changes++ } } + runCurrent() + } + + @Test + fun fanOut() = runFrpTest { network -> + val e = network.mutableTFlow<Map<String, Int>>() + val (fooFlow, barFlow) = + activateSpecWithResult(network) { + val selector = e.groupByKey() + val foos = selector.eventsForKey("foo") + val bars = selector.eventsForKey("bar") + foos.toSharedFlow() to bars.toSharedFlow() + } + val stateFlow = fooFlow.stateIn(backgroundScope, SharingStarted.Eagerly, null) + backgroundScope.launch { barFlow.collect { error("unexpected bar") } } + runCurrent() + + assertEquals(null, stateFlow.value) + + e.emit(mapOf("foo" to 1)) + runCurrent() + + assertEquals(1, stateFlow.value) + } + + @Test + fun fanOutLateSubscribe() = runFrpTest { network -> + val e = network.mutableTFlow<Map<String, Int>>() + val barFlow = + activateSpecWithResult(network) { + val selector = e.groupByKey() + selector + .eventsForKey("foo") + .map { selector.eventsForKey("bar") } + .hold(emptyTFlow) + .switchPromptly() + .toSharedFlow() + } + val stateFlow = barFlow.stateIn(backgroundScope, SharingStarted.Eagerly, null) + runCurrent() + + assertEquals(null, stateFlow.value) + + e.emit(mapOf("foo" to 0, "bar" to 1)) + runCurrent() + + assertEquals(1, stateFlow.value) + } + + @Test + fun inputFlowCompleted() = runFrpTest { network -> + val results = mutableListOf<Int>() + val e = network.mutableTFlow<Int>() + activateSpec(network) { e.nextOnly().observe { results.add(it) } } + runCurrent() + + e.emit(10) + runCurrent() + + assertEquals(listOf(10), results) + + e.emit(20) + runCurrent() + assertEquals(listOf(10), results) + } + + @Test + fun fanOutThenMergeIncrementally() = runFrpTest { network -> + // A tflow of group updates, where a group is a tflow of child updates, where a child is a + // stateflow + val e = network.mutableTFlow<Map<Int, Maybe<TFlow<Map<Int, Maybe<StateFlow<String>>>>>>>() + println("fanOutMergeInc START") + val state = + activateSpecWithResult(network) { + // Convert nested Flows to nested TFlow/TState + val emitter: TFlow<Map<Int, Maybe<TFlow<Map<Int, Maybe<TState<String>>>>>>> = + e.mapBuild { m -> + m.mapValues { (_, mFlow) -> + mFlow.map { + it.mapBuild { m2 -> + m2.mapValues { (_, mState) -> + mState.map { stateFlow -> stateFlow.toTState() } + } + } + } + } + } + // Accumulate all of our updates into a single TState + val accState: TState<Map<Int, Map<Int, String>>> = + emitter + .mapStateful { + changeMap: Map<Int, Maybe<TFlow<Map<Int, Maybe<TState<String>>>>>> -> + changeMap.mapValues { (groupId, mGroupChanges) -> + mGroupChanges.map { + groupChanges: TFlow<Map<Int, Maybe<TState<String>>>> -> + // New group + val childChangeById = groupChanges.groupByKey() + val map: TFlow<Map<Int, Maybe<TFlow<Maybe<TState<String>>>>>> = + groupChanges.mapStateful { + gChangeMap: Map<Int, Maybe<TState<String>>> -> + gChangeMap.mapValues { (childId, mChild) -> + mChild.map { child: TState<String> -> + println("new child $childId in the house") + // New child + val eRemoved = + childChangeById + .eventsForKey(childId) + .filter { it === None } + .nextOnly() + + val addChild: TFlow<Maybe<TState<String>>> = + now.map { mChild } + .onEach { + println( + "addChild (groupId=$groupId, childId=$childId) ${child.sample()}" + ) + } + + val removeChild: TFlow<Maybe<TState<String>>> = + eRemoved + .onEach { + println( + "removeChild (groupId=$groupId, childId=$childId)" + ) + } + .map { none() } + + addChild.mergeWith(removeChild) { _, _ -> + error("unexpected coincidence") + } + } + } + } + val mergeIncrementally: TFlow<Map<Int, Maybe<TState<String>>>> = + map.onEach { println("merge patch: $it") } + .mergeIncrementallyPromptly() + mergeIncrementally + .onEach { println("patch: $it") } + .foldMapIncrementally() + .flatMap { it.combineValues() } + } + } + } + .foldMapIncrementally() + .flatMap { it.combineValues() } + + accState.toStateFlow() + } + runCurrent() + + assertEquals(emptyMap(), state.value) + + val emitter2 = network.mutableTFlow<Map<Int, Maybe<StateFlow<String>>>>() + println() + println("init outer 0") + e.emit(mapOf(0 to just(emitter2.onEach { println("emitter2 emit: $it") }))) + runCurrent() + + assertEquals(mapOf(0 to emptyMap()), state.value) + + println() + println("init inner 10") + emitter2.emit(mapOf(10 to just(MutableStateFlow("(0, 10)")))) + runCurrent() + + assertEquals(mapOf(0 to mapOf(10 to "(0, 10)")), state.value) + + // replace + println() + println("replace inner 10") + emitter2.emit(mapOf(10 to just(MutableStateFlow("(1, 10)")))) + runCurrent() + + assertEquals(mapOf(0 to mapOf(10 to "(1, 10)")), state.value) + + // remove + emitter2.emit(mapOf(10 to none())) + runCurrent() + + assertEquals(mapOf(0 to emptyMap()), state.value) + + // add again + emitter2.emit(mapOf(10 to just(MutableStateFlow("(2, 10)")))) + runCurrent() + + assertEquals(mapOf(0 to mapOf(10 to "(2, 10)")), state.value) + + // batch update + emitter2.emit( + mapOf( + 10 to none(), + 11 to just(MutableStateFlow("(0, 11)")), + 12 to just(MutableStateFlow("(0, 12)")), + ) + ) + runCurrent() + + assertEquals(mapOf(0 to mapOf(11 to "(0, 11)", 12 to "(0, 12)")), state.value) + } + + @Test + fun applyLatestNetworkChanges() = runFrpTest { network -> + val newCount = network.mutableTFlow<FrpSpec<Flow<Int>>>() + val flowOfFlows: Flow<Flow<Int>> = + activateSpecWithResult(network) { newCount.applyLatestSpec().toSharedFlow() } + runCurrent() + + val incCount = network.mutableTFlow<Unit>() + fun newFlow(): FrpSpec<SharedFlow<Int>> = frpSpec { + launchEffect { + try { + println("new flow!") + awaitCancellation() + } finally { + println("cancelling old flow") + } + } + lateinit var count: TState<Int> + count = + incCount + .onEach { println("incrementing ${count.sample()}") } + .fold(0) { _, c -> c + 1 } + count.stateChanges.toSharedFlow() + } + + var outerCount = 0 + val lastFlows: StateFlow<Pair<StateFlow<Int?>, StateFlow<Int?>>> = + flowOfFlows + .map { it.stateIn(backgroundScope, SharingStarted.Eagerly, null) } + .pairwise(MutableStateFlow(null)) + .onEach { outerCount++ } + .stateIn( + backgroundScope, + SharingStarted.Eagerly, + MutableStateFlow(null) to MutableStateFlow(null), + ) + + runCurrent() + + newCount.emit(newFlow()) + runCurrent() + + assertEquals(1, outerCount) + // assertEquals(1, incCount.subscriptionCount) + assertNull(lastFlows.value.second.value) + + incCount.emit(Unit) + runCurrent() + + println("checking") + assertEquals(1, lastFlows.value.second.value) + + incCount.emit(Unit) + runCurrent() + + assertEquals(2, lastFlows.value.second.value) + + newCount.emit(newFlow()) + runCurrent() + incCount.emit(Unit) + runCurrent() + + // verify old flow is not getting updates + assertEquals(2, lastFlows.value.first.value) + // but the new one is + assertEquals(1, lastFlows.value.second.value) + } + + @Test + fun effect() = runFrpTest { network -> + val input = network.mutableTFlow<Unit>() + var effectRunning = false + var count = 0 + activateSpec(network) { + val j = launchEffect { + effectRunning = true + try { + awaitCancellation() + } finally { + effectRunning = false + } + } + merge(emptyTFlow, input.nextOnly()).observe { + count++ + j.cancel() + } + } + runCurrent() + assertEquals(true, effectRunning) + assertEquals(0, count) + + println("1") + input.emit(Unit) + assertEquals(false, effectRunning) + assertEquals(1, count) + + println("2") + input.emit(Unit) + assertEquals(1, count) + println("3") + input.emit(Unit) + assertEquals(1, count) + } + + private fun runFrpTest( + timeout: Duration = 3.seconds, + block: suspend TestScope.(FrpNetwork) -> Unit, + ) { + runTest(timeout = timeout) { + val network = backgroundScope.newFrpNetwork() + runCurrent() + block(network) + } + } + + private fun TestScope.activateSpec(network: FrpNetwork, spec: FrpSpec<*>) = + backgroundScope.launch { network.activateSpec(spec) } + + private suspend fun <R> TestScope.activateSpecWithResult( + network: FrpNetwork, + spec: FrpSpec<R>, + ): R = + CompletableDeferred<R>() + .apply { activateSpec(network) { complete(spec.applySpec()) } } + .await() +} + +private fun <T> assertEquals(expected: T, actual: T) = + org.junit.Assert.assertEquals(expected, actual) + +private fun <A> Flow<A>.pairwise(init: A): Flow<Pair<A, A>> = flow { + var prev = init + collect { + emit(prev to it) + prev = it + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt index 75a77cf781d2..194b41fbeaea 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt @@ -27,12 +27,23 @@ import android.hardware.face.FaceSensorProperties import android.hardware.face.FaceSensorPropertiesInternal import android.hardware.fingerprint.FingerprintSensorProperties import android.hardware.fingerprint.FingerprintSensorPropertiesInternal +import com.android.keyguard.keyguardUpdateMonitor +import com.android.systemui.SysuiTestableContext +import com.android.systemui.biometrics.data.repository.biometricStatusRepository +import com.android.systemui.biometrics.shared.model.AuthenticationReason +import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.res.R +import com.android.systemui.util.mockito.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent /** Create [FingerprintSensorPropertiesInternal] for a test. */ internal fun fingerprintSensorPropertiesInternal( ids: List<Int> = listOf(0), strong: Boolean = true, - sensorType: Int = FingerprintSensorProperties.TYPE_REAR + sensorType: Int = FingerprintSensorProperties.TYPE_REAR, ): List<FingerprintSensorPropertiesInternal> { val componentInfo = listOf( @@ -41,15 +52,15 @@ internal fun fingerprintSensorPropertiesInternal( "vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */, "00000001" /* serialNumber */, - "" /* softwareVersion */ + "", /* softwareVersion */ ), ComponentInfoInternal( "matchingAlgorithm" /* componentId */, "" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */, - "vendor/version/revision" /* softwareVersion */ - ) + "vendor/version/revision", /* softwareVersion */ + ), ) return ids.map { id -> FingerprintSensorPropertiesInternal( @@ -58,7 +69,7 @@ internal fun fingerprintSensorPropertiesInternal( 5 /* maxEnrollmentsPerUser */, componentInfo, sensorType, - false /* resetLockoutRequiresHardwareAuthToken */ + false, /* resetLockoutRequiresHardwareAuthToken */ ) } } @@ -75,15 +86,15 @@ internal fun faceSensorPropertiesInternal( "vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */, "00000001" /* serialNumber */, - "" /* softwareVersion */ + "", /* softwareVersion */ ), ComponentInfoInternal( "matchingAlgorithm" /* componentId */, "" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */, - "vendor/version/revision" /* softwareVersion */ - ) + "vendor/version/revision", /* softwareVersion */ + ), ) return ids.map { id -> FaceSensorPropertiesInternal( @@ -94,7 +105,7 @@ internal fun faceSensorPropertiesInternal( FaceSensorProperties.TYPE_RGB, true /* supportsFaceDetection */, true /* supportsSelfIllumination */, - false /* resetLockoutRequiresHardwareAuthToken */ + false, /* resetLockoutRequiresHardwareAuthToken */ ) } } @@ -145,3 +156,67 @@ internal fun promptInfo( info.negativeButtonText = negativeButton return info } + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun TestScope.updateSfpsIndicatorRequests( + kosmos: Kosmos, + mContext: SysuiTestableContext, + primaryBouncerRequest: Boolean? = null, + alternateBouncerRequest: Boolean? = null, + biometricPromptRequest: Boolean? = null, + // TODO(b/365182034): update when rest to unlock feature is implemented + // progressBarShowing: Boolean? = null +) { + biometricPromptRequest?.let { hasBiometricPromptRequest -> + if (hasBiometricPromptRequest) { + kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( + AuthenticationReason.BiometricPromptAuthentication + ) + } else { + kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( + AuthenticationReason.NotRunning + ) + } + } + + primaryBouncerRequest?.let { hasPrimaryBouncerRequest -> + updatePrimaryBouncer( + kosmos, + mContext, + isShowing = hasPrimaryBouncerRequest, + isAnimatingAway = false, + fpsDetectionRunning = true, + isUnlockingWithFpAllowed = true, + ) + } + + alternateBouncerRequest?.let { hasAlternateBouncerRequest -> + kosmos.keyguardBouncerRepository.setAlternateVisible(hasAlternateBouncerRequest) + } + + // TODO(b/365182034): set progress bar visibility when rest to unlock feature is implemented + + runCurrent() +} + +internal fun updatePrimaryBouncer( + kosmos: Kosmos, + mContext: SysuiTestableContext, + isShowing: Boolean, + isAnimatingAway: Boolean, + fpsDetectionRunning: Boolean, + isUnlockingWithFpAllowed: Boolean, +) { + kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing) + kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false) + val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null + kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation( + primaryStartDisappearAnimation + ) + + whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning) + .thenReturn(fpsDetectionRunning) + whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed) + .thenReturn(isUnlockingWithFpAllowed) + mContext.orCreateTestableResources.addOverride(R.bool.config_show_sidefps_hint_on_bouncer, true) +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt index 7fa165c19f60..57df66207380 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt @@ -16,64 +16,48 @@ package com.android.systemui.biometrics.ui.binder -import android.animation.Animator -import android.graphics.Rect -import android.hardware.biometrics.SensorLocationInternal -import android.hardware.display.DisplayManager -import android.hardware.display.DisplayManagerGlobal import android.testing.TestableLooper -import android.view.Display -import android.view.DisplayInfo import android.view.LayoutInflater import android.view.View -import android.view.ViewPropertyAnimator -import android.view.WindowInsets import android.view.WindowManager -import android.view.WindowMetrics import android.view.layoutInflater import android.view.windowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.airbnb.lottie.LottieAnimationView -import com.android.keyguard.keyguardUpdateMonitor import com.android.systemui.SysuiTestCase -import com.android.systemui.biometrics.FingerprintInteractiveToAuthProvider -import com.android.systemui.biometrics.data.repository.biometricStatusRepository import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository -import com.android.systemui.biometrics.shared.model.AuthenticationReason import com.android.systemui.biometrics.shared.model.DisplayRotation import com.android.systemui.biometrics.shared.model.FingerprintSensorType import com.android.systemui.biometrics.shared.model.SensorStrength -import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository +import com.android.systemui.biometrics.updateSfpsIndicatorRequests import com.android.systemui.display.data.repository.displayRepository import com.android.systemui.display.data.repository.displayStateRepository -import com.android.systemui.keyguard.ui.viewmodel.sideFpsProgressBarViewModel import com.android.systemui.kosmos.testScope import com.android.systemui.res.R import com.android.systemui.testKosmos import com.android.systemui.util.mockito.eq -import com.android.systemui.util.mockito.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor import org.mockito.Mock -import org.mockito.Mockito import org.mockito.Mockito.any import org.mockito.Mockito.inOrder import org.mockito.Mockito.mock import org.mockito.Mockito.never -import org.mockito.Mockito.spy import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule -import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.firstValue @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @@ -83,84 +67,25 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { private val kosmos = testKosmos() @JvmField @Rule var mockitoRule: MockitoRule = MockitoJUnit.rule() - @Mock private lateinit var displayManager: DisplayManager - @Mock - private lateinit var fingerprintInteractiveToAuthProvider: FingerprintInteractiveToAuthProvider @Mock private lateinit var layoutInflater: LayoutInflater @Mock private lateinit var sideFpsView: View - - private val contextDisplayInfo = DisplayInfo() - - private var displayWidth: Int = 0 - private var displayHeight: Int = 0 - private var boundsWidth: Int = 0 - private var boundsHeight: Int = 0 - - private lateinit var deviceConfig: DeviceConfig - private lateinit var sensorLocation: SensorLocationInternal - - enum class DeviceConfig { - X_ALIGNED, - Y_ALIGNED, - } + @Captor private lateinit var viewCaptor: ArgumentCaptor<View> @Before fun setup() { allowTestableLooperAsMainThread() // repeatWhenAttached requires the main thread - - mContext = spy(mContext) - - val resources = mContext.resources - whenever(mContext.display) - .thenReturn( - Display(mock(DisplayManagerGlobal::class.java), 1, contextDisplayInfo, resources) - ) - kosmos.layoutInflater = layoutInflater - - whenever(fingerprintInteractiveToAuthProvider.enabledForCurrentUser) - .thenReturn(MutableStateFlow(false)) - - context.addMockSystemService(DisplayManager::class.java, displayManager) context.addMockSystemService(WindowManager::class.java, kosmos.windowManager) - `when`(layoutInflater.inflate(R.layout.sidefps_view, null, false)).thenReturn(sideFpsView) `when`(sideFpsView.requireViewById<LottieAnimationView>(eq(R.id.sidefps_animation))) .thenReturn(mock(LottieAnimationView::class.java)) - with(mock(ViewPropertyAnimator::class.java)) { - `when`(sideFpsView.animate()).thenReturn(this) - `when`(alpha(Mockito.anyFloat())).thenReturn(this) - `when`(setStartDelay(Mockito.anyLong())).thenReturn(this) - `when`(setDuration(Mockito.anyLong())).thenReturn(this) - `when`(setListener(any())).thenAnswer { - (it.arguments[0] as Animator.AnimatorListener).onAnimationEnd( - mock(Animator::class.java) - ) - this - } - } } @Test fun verifyIndicatorNotAdded_whenInRearDisplayMode() { kosmos.testScope.runTest { - setupTestConfiguration( - DeviceConfig.X_ALIGNED, - rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = true - ) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - updatePrimaryBouncer( - isShowing = true, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) - runCurrent() - + setupTestConfiguration(isInRearDisplayMode = true) + updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true) verify(kosmos.windowManager, never()).addView(any(), any()) } } @@ -168,33 +93,14 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { @Test fun verifyIndicatorShowAndHide_onPrimaryBouncerShowAndHide() { kosmos.testScope.runTest { - setupTestConfiguration( - DeviceConfig.X_ALIGNED, - rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false - ) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - // Show primary bouncer - updatePrimaryBouncer( - isShowing = true, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + setupTestConfiguration(isInRearDisplayMode = false) + updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true) runCurrent() verify(kosmos.windowManager).addView(any(), any()) // Hide primary bouncer - updatePrimaryBouncer( - isShowing = false, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = false) runCurrent() verify(kosmos.windowManager).removeView(any()) @@ -204,30 +110,19 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { @Test fun verifyIndicatorShowAndHide_onAlternateBouncerShowAndHide() { kosmos.testScope.runTest { - setupTestConfiguration( - DeviceConfig.X_ALIGNED, - rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false - ) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - // Show alternate bouncer - kosmos.keyguardBouncerRepository.setAlternateVisible(true) + setupTestConfiguration(isInRearDisplayMode = false) + updateSfpsIndicatorRequests(kosmos, mContext, alternateBouncerRequest = true) runCurrent() verify(kosmos.windowManager).addView(any(), any()) - var viewCaptor = argumentCaptor<View>() verify(kosmos.windowManager).addView(viewCaptor.capture(), any()) verify(viewCaptor.firstValue) .announceForAccessibility( mContext.getText(R.string.accessibility_side_fingerprint_indicator_label) ) - // Hide alternate bouncer - kosmos.keyguardBouncerRepository.setAlternateVisible(false) + updateSfpsIndicatorRequests(kosmos, mContext, alternateBouncerRequest = false) runCurrent() verify(kosmos.windowManager).removeView(any()) @@ -237,30 +132,14 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { @Test fun verifyIndicatorShownAndHidden_onSystemServerAuthenticationStartedAndStopped() { kosmos.testScope.runTest { - setupTestConfiguration( - DeviceConfig.X_ALIGNED, - rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - updatePrimaryBouncer( - isShowing = false, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) - // System server authentication started - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.BiometricPromptAuthentication - ) + setupTestConfiguration(isInRearDisplayMode = false) + updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true) runCurrent() verify(kosmos.windowManager).addView(any(), any()) // System server authentication stopped - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) + updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = false) runCurrent() verify(kosmos.windowManager).removeView(any()) @@ -269,45 +148,37 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { // On progress bar shown - hide indicator // On progress bar hidden - show indicator + // TODO(b/365182034): update + enable when rest to unlock feature is implemented + @Ignore("b/365182034") @Test fun verifyIndicatorProgressBarInteraction() { kosmos.testScope.runTest { // Pre-auth conditions - setupTestConfiguration( - DeviceConfig.X_ALIGNED, - rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false - ) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - - // Show primary bouncer - updatePrimaryBouncer( - isShowing = true, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + setupTestConfiguration(isInRearDisplayMode = false) + updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true) runCurrent() val inOrder = inOrder(kosmos.windowManager) - // Verify indicator shown inOrder.verify(kosmos.windowManager).addView(any(), any()) // Set progress bar visible - kosmos.sideFpsProgressBarViewModel.setVisible(true) - + updateSfpsIndicatorRequests( + kosmos, + mContext, + primaryBouncerRequest = true, + ) // , progressBarShowing = true) runCurrent() // Verify indicator hidden inOrder.verify(kosmos.windowManager).removeView(any()) // Set progress bar invisible - kosmos.sideFpsProgressBarViewModel.setVisible(false) - + updateSfpsIndicatorRequests( + kosmos, + mContext, + primaryBouncerRequest = true, + ) // , progressBarShowing = false) runCurrent() // Verify indicator shown @@ -315,78 +186,18 @@ class SideFpsOverlayViewBinderTest : SysuiTestCase() { } } - private fun updatePrimaryBouncer( - isShowing: Boolean, - isAnimatingAway: Boolean, - fpsDetectionRunning: Boolean, - isUnlockingWithFpAllowed: Boolean, - ) { - kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing) - kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false) - val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null - kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation( - primaryStartDisappearAnimation - ) - - whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning) - .thenReturn(fpsDetectionRunning) - whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed) - .thenReturn(isUnlockingWithFpAllowed) - mContext.orCreateTestableResources.addOverride( - R.bool.config_show_sidefps_hint_on_bouncer, - true - ) - } - - private suspend fun TestScope.setupTestConfiguration( - deviceConfig: DeviceConfig, - rotation: DisplayRotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode: Boolean, - ) { - this@SideFpsOverlayViewBinderTest.deviceConfig = deviceConfig - - when (deviceConfig) { - DeviceConfig.X_ALIGNED -> { - displayWidth = 3000 - displayHeight = 1500 - boundsWidth = 200 - boundsHeight = 100 - sensorLocation = SensorLocationInternal("", 2500, 0, boundsWidth / 2) - } - DeviceConfig.Y_ALIGNED -> { - displayWidth = 2500 - displayHeight = 2000 - boundsWidth = 100 - boundsHeight = 200 - sensorLocation = SensorLocationInternal("", displayWidth, 300, boundsHeight / 2) - } - } - - whenever(kosmos.windowManager.maximumWindowMetrics) - .thenReturn( - WindowMetrics( - Rect(0, 0, displayWidth, displayHeight), - mock(WindowInsets::class.java), - ) - ) - - contextDisplayInfo.uniqueId = DISPLAY_ID - + private suspend fun TestScope.setupTestConfiguration(isInRearDisplayMode: Boolean) { kosmos.fingerprintPropertyRepository.setProperties( sensorId = 1, strength = SensorStrength.STRONG, sensorType = FingerprintSensorType.POWER_BUTTON, - sensorLocations = mapOf(DISPLAY_ID to sensorLocation) + sensorLocations = emptyMap(), ) kosmos.displayStateRepository.setIsInRearDisplayMode(isInRearDisplayMode) - kosmos.displayStateRepository.setCurrentRotation(rotation) + kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0) kosmos.displayRepository.emitDisplayChangeEvent(0) kosmos.sideFpsOverlayViewBinder.start() runCurrent() } - - companion object { - private const val DISPLAY_ID = "displayId" - } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt index 0db7b62b8ef1..84d062a3e7be 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt @@ -30,23 +30,19 @@ import android.view.windowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.airbnb.lottie.model.KeyPath -import com.android.keyguard.keyguardUpdateMonitor import com.android.settingslib.Utils import com.android.systemui.SysuiTestCase import com.android.systemui.biometrics.FingerprintInteractiveToAuthProvider -import com.android.systemui.biometrics.data.repository.biometricStatusRepository import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository import com.android.systemui.biometrics.domain.interactor.displayStateInteractor -import com.android.systemui.biometrics.shared.model.AuthenticationReason import com.android.systemui.biometrics.shared.model.DisplayRotation import com.android.systemui.biometrics.shared.model.FingerprintSensorType import com.android.systemui.biometrics.shared.model.LottieCallback import com.android.systemui.biometrics.shared.model.SensorStrength -import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository +import com.android.systemui.biometrics.updateSfpsIndicatorRequests import com.android.systemui.coroutines.collectLastValue import com.android.systemui.display.data.repository.displayRepository import com.android.systemui.display.data.repository.displayStateRepository -import com.android.systemui.keyguard.ui.viewmodel.sideFpsProgressBarViewModel import com.android.systemui.kosmos.testScope import com.android.systemui.res.R import com.android.systemui.testKosmos @@ -84,17 +80,17 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { private val indicatorColor = Utils.getColorAttrDefaultColor( context, - com.android.internal.R.attr.materialColorPrimaryFixed + com.android.internal.R.attr.materialColorPrimaryFixed, ) private val outerRimColor = Utils.getColorAttrDefaultColor( context, - com.android.internal.R.attr.materialColorPrimaryFixedDim + com.android.internal.R.attr.materialColorPrimaryFixedDim, ) private val chevronFill = Utils.getColorAttrDefaultColor( context, - com.android.internal.R.attr.materialColorOnPrimaryFixed + com.android.internal.R.attr.materialColorOnPrimaryFixed, ) private val color_blue400 = context.getColor(com.android.settingslib.color.R.color.settingslib_color_blue400) @@ -133,7 +129,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { setupTestConfiguration( DeviceConfig.X_ALIGNED, rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false + isInRearDisplayMode = false, ) val overlayViewProperties by @@ -167,7 +163,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { setupTestConfiguration( DeviceConfig.Y_ALIGNED, rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false + isInRearDisplayMode = false, ) val overlayViewProperties by @@ -201,7 +197,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { setupTestConfiguration( DeviceConfig.X_ALIGNED, rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false + isInRearDisplayMode = false, ) val overlayViewParams by @@ -243,7 +239,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { setupTestConfiguration( DeviceConfig.Y_ALIGNED, rotation = DisplayRotation.ROTATION_0, - isInRearDisplayMode = false + isInRearDisplayMode = false, ) val overlayViewParams by @@ -284,17 +280,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { kosmos.testScope.runTest { val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.NotRunning - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - - updatePrimaryBouncer( - isShowing = true, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true) runCurrent() assertThat(lottieCallbacks) @@ -312,17 +298,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks) setDarkMode(true) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.BiometricPromptAuthentication - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - - updatePrimaryBouncer( - isShowing = false, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true) runCurrent() assertThat(lottieCallbacks) @@ -338,17 +314,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks) setDarkMode(false) - kosmos.biometricStatusRepository.setFingerprintAuthenticationReason( - AuthenticationReason.BiometricPromptAuthentication - ) - kosmos.sideFpsProgressBarViewModel.setVisible(false) - - updatePrimaryBouncer( - isShowing = false, - isAnimatingAway = false, - fpsDetectionRunning = true, - isUnlockingWithFpAllowed = true - ) + updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true) runCurrent() assertThat(lottieCallbacks) @@ -371,29 +337,6 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { mContext.resources.configuration.uiMode = uiMode } - private fun updatePrimaryBouncer( - isShowing: Boolean, - isAnimatingAway: Boolean, - fpsDetectionRunning: Boolean, - isUnlockingWithFpAllowed: Boolean, - ) { - kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing) - kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false) - val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null - kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation( - primaryStartDisappearAnimation - ) - - whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning) - .thenReturn(fpsDetectionRunning) - whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed) - .thenReturn(isUnlockingWithFpAllowed) - mContext.orCreateTestableResources.addOverride( - R.bool.config_show_sidefps_hint_on_bouncer, - true - ) - } - private suspend fun TestScope.setupTestConfiguration( deviceConfig: DeviceConfig, rotation: DisplayRotation = DisplayRotation.ROTATION_0, @@ -432,7 +375,7 @@ class SideFpsOverlayViewModelTest : SysuiTestCase() { sensorId = 1, strength = SensorStrength.STRONG, sensorType = FingerprintSensorType.POWER_BUTTON, - sensorLocations = mapOf(DISPLAY_ID to sensorLocation) + sensorLocations = mapOf(DISPLAY_ID to sensorLocation), ) kosmos.displayStateRepository.setIsInRearDisplayMode(isInRearDisplayMode) diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt index 7da2e9a8a283..fc9e595945dd 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt @@ -408,6 +408,40 @@ class MediaDataLoaderTest : SysuiTestCase() { verify(mockImageLoader, times(1)).loadBitmap(any(), anyInt(), anyInt(), anyInt()) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testLoadMediaDataInBg_fromResumeToActive_doesNotCancelResumeToActiveTask() = + testScope.runTest { + val mockImageLoader = mock<ImageLoader>() + val mediaDataLoader = + MediaDataLoader( + context, + testDispatcher, + testScope, + mediaControllerFactory, + mediaFlags, + mockImageLoader, + statusBarManager, + ) + metadataBuilder.putString( + MediaMetadata.METADATA_KEY_ALBUM_ART_URI, + "content://album_art_uri", + ) + + testScope.launch { + mediaDataLoader.loadMediaData( + KEY, + createMediaNotification(), + isConvertingToActive = true, + ) + } + testScope.launch { mediaDataLoader.loadMediaData(KEY, createMediaNotification()) } + testScope.launch { mediaDataLoader.loadMediaData(KEY, createMediaNotification()) } + testScope.advanceUntilIdle() + + verify(mockImageLoader, times(2)).loadBitmap(any(), anyInt(), anyInt(), anyInt()) + } + private fun createMediaNotification( mediaSession: MediaSession? = session, applicationInfo: ApplicationInfo? = null, diff --git a/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml b/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml index 3b3ed39c8993..91cd019c85d1 100644 --- a/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml +++ b/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml @@ -215,17 +215,4 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="1.0" tools:srcCompat="@tools:sample/avatars" /> - - <com.android.systemui.biometrics.BiometricPromptLottieViewWrapper - android:id="@+id/biometric_icon_overlay" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_gravity="center" - android:contentDescription="@null" - android:scaleType="fitXY" - android:importantForAccessibility="no" - app:layout_constraintBottom_toBottomOf="@+id/biometric_icon" - app:layout_constraintEnd_toEndOf="@+id/biometric_icon" - app:layout_constraintStart_toStartOf="@+id/biometric_icon" - app:layout_constraintTop_toTopOf="@+id/biometric_icon" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml b/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml index 2a00495e9d01..51117a7845df 100644 --- a/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml +++ b/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml @@ -40,19 +40,6 @@ android:layout_height="match_parent"> app:layout_constraintTop_toTopOf="parent" tools:srcCompat="@tools:sample/avatars" /> - <com.android.systemui.biometrics.BiometricPromptLottieViewWrapper - android:id="@+id/biometric_icon_overlay" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_gravity="center" - android:contentDescription="@null" - android:scaleType="fitXY" - android:importantForAccessibility="no" - app:layout_constraintBottom_toBottomOf="@+id/biometric_icon" - app:layout_constraintEnd_toEndOf="@+id/biometric_icon" - app:layout_constraintStart_toStartOf="@+id/biometric_icon" - app:layout_constraintTop_toTopOf="@+id/biometric_icon" /> - <ScrollView android:id="@+id/scrollView" android:layout_width="0dp" diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt index 73f75a4ff639..18446f02778a 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt @@ -18,13 +18,11 @@ package com.android.systemui.biometrics.ui.binder import android.animation.Animator import android.animation.AnimatorSet -import android.animation.ValueAnimator import android.graphics.Outline import android.graphics.Rect import android.transition.AutoTransition import android.transition.TransitionManager import android.util.TypedValue -import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.ViewOutlineProvider @@ -52,7 +50,6 @@ import com.android.systemui.lifecycle.repeatWhenAttached import com.android.systemui.res.R import kotlin.math.abs import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** Helper for [BiometricViewBinder] to handle resize transitions. */ @@ -98,7 +95,7 @@ object BiometricViewSizeBinder { TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 1f, - view.resources.displayMetrics + view.resources.displayMetrics, ) val cornerRadiusPx = (pxToDp * cornerRadius).toInt() @@ -114,7 +111,7 @@ object BiometricViewSizeBinder { 0, view.width + cornerRadiusPx, view.height, - cornerRadiusPx.toFloat() + cornerRadiusPx.toFloat(), ) } PromptPosition.Left -> { @@ -123,7 +120,7 @@ object BiometricViewSizeBinder { 0, view.width, view.height, - cornerRadiusPx.toFloat() + cornerRadiusPx.toFloat(), ) } PromptPosition.Bottom, @@ -133,7 +130,7 @@ object BiometricViewSizeBinder { 0, view.width, view.height + cornerRadiusPx, - cornerRadiusPx.toFloat() + cornerRadiusPx.toFloat(), ) } } @@ -160,16 +157,13 @@ object BiometricViewSizeBinder { fun setVisibilities(hideSensorIcon: Boolean, size: PromptSize) { viewsToHideWhenSmall.forEach { it.showContentOrHide(forceHide = size.isSmall) } largeConstraintSet.setVisibility(iconHolderView.id, View.GONE) - largeConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE) largeConstraintSet.setVisibility(R.id.indicator, View.GONE) largeConstraintSet.setVisibility(R.id.scrollView, View.GONE) if (hideSensorIcon) { smallConstraintSet.setVisibility(iconHolderView.id, View.GONE) - smallConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE) smallConstraintSet.setVisibility(R.id.indicator, View.GONE) mediumConstraintSet.setVisibility(iconHolderView.id, View.GONE) - mediumConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE) mediumConstraintSet.setVisibility(R.id.indicator, View.GONE) } } @@ -189,24 +183,24 @@ object BiometricViewSizeBinder { R.id.biometric_icon, ConstraintSet.LEFT, ConstraintSet.PARENT_ID, - ConstraintSet.LEFT + ConstraintSet.LEFT, ) mediumConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.LEFT, - position.left + position.left, ) smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.RIGHT) smallConstraintSet.connect( R.id.biometric_icon, ConstraintSet.LEFT, ConstraintSet.PARENT_ID, - ConstraintSet.LEFT + ConstraintSet.LEFT, ) smallConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.LEFT, - position.left + position.left, ) } if (position.top != 0) { @@ -216,13 +210,13 @@ object BiometricViewSizeBinder { mediumConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.TOP, - position.top + position.top, ) smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.BOTTOM) smallConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.TOP, - position.top + position.top, ) } if (position.right != 0) { @@ -233,24 +227,24 @@ object BiometricViewSizeBinder { R.id.biometric_icon, ConstraintSet.RIGHT, ConstraintSet.PARENT_ID, - ConstraintSet.RIGHT + ConstraintSet.RIGHT, ) mediumConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.RIGHT, - position.right + position.right, ) smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.LEFT) smallConstraintSet.connect( R.id.biometric_icon, ConstraintSet.RIGHT, ConstraintSet.PARENT_ID, - ConstraintSet.RIGHT + ConstraintSet.RIGHT, ) smallConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.RIGHT, - position.right + position.right, ) } if (position.bottom != 0) { @@ -260,13 +254,13 @@ object BiometricViewSizeBinder { mediumConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.BOTTOM, - position.bottom + position.bottom, ) smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.TOP) smallConstraintSet.setMargin( R.id.biometric_icon, ConstraintSet.BOTTOM, - position.bottom + position.bottom, ) } iconHolderView.layoutParams = iconParams @@ -305,11 +299,11 @@ object BiometricViewSizeBinder { } else if (bounds.right < 0) { mediumConstraintSet.setGuidelineBegin( rightGuideline.id, - abs(bounds.right) + abs(bounds.right), ) smallConstraintSet.setGuidelineBegin( rightGuideline.id, - abs(bounds.right) + abs(bounds.right), ) } @@ -362,13 +356,13 @@ object BiometricViewSizeBinder { R.id.scrollView, ConstraintSet.LEFT, R.id.midGuideline, - ConstraintSet.LEFT + ConstraintSet.LEFT, ) flipConstraintSet.connect( R.id.scrollView, ConstraintSet.RIGHT, R.id.rightGuideline, - ConstraintSet.RIGHT + ConstraintSet.RIGHT, ) } else if (position.isTop) { // Top position is only used for 180 rotation Udfps @@ -377,24 +371,24 @@ object BiometricViewSizeBinder { R.id.scrollView, ConstraintSet.TOP, R.id.indicator, - ConstraintSet.BOTTOM + ConstraintSet.BOTTOM, ) mediumConstraintSet.connect( R.id.scrollView, ConstraintSet.BOTTOM, R.id.button_bar, - ConstraintSet.TOP + ConstraintSet.TOP, ) mediumConstraintSet.connect( R.id.panel, ConstraintSet.TOP, R.id.biometric_icon, - ConstraintSet.TOP + ConstraintSet.TOP, ) mediumConstraintSet.setMargin( R.id.panel, ConstraintSet.TOP, - (-24 * pxToDp).toInt() + (-24 * pxToDp).toInt(), ) mediumConstraintSet.setVerticalBias(R.id.scrollView, 0f) } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt b/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt new file mode 100644 index 000000000000..08a79c92919f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.util + +import android.app.ActivityManager +import android.content.res.Resources +import com.android.systemui.res.R +import java.io.File + +private const val ENABLE_MENU_KEY_FILE = "/data/local/enable_menu_key" + +/** + * In general, we enable unlocking the insecure keyguard with the menu key. However, there are some + * cases where we wish to disable it, notably when the menu button placement or technology is prone + * to false positives. + * + * @return true if the menu key should be enabled + */ +fun Resources.shouldEnableMenuKey(): Boolean { + val configDisabled = getBoolean(R.bool.config_disableMenuKeyInLockScreen) + val isTestHarness = ActivityManager.isRunningInTestHarness() + val fileOverride = File(ENABLE_MENU_KEY_FILE).exists() + return !configDisabled || isTestHarness || fileOverride +} diff --git a/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt b/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt new file mode 100644 index 000000000000..dc07cca08d7b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.display.data.repository + +import android.annotation.MainThread +import android.view.Display.DEFAULT_DISPLAY +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.LogLevel +import com.android.systemui.log.dagger.FocusedDisplayRepoLog +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import com.android.wm.shell.shared.FocusTransitionListener +import com.android.wm.shell.shared.ShellTransitions +import java.util.concurrent.Executor +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn + +/** Repository tracking display focus. */ +@SysUISingleton +@MainThread +class FocusedDisplayRepository +@Inject +constructor( + @Application val scope: CoroutineScope, + @Main private val mainExecutor: Executor, + transitions: ShellTransitions, + @FocusedDisplayRepoLog logBuffer: LogBuffer, +) { + val focusedTask: Flow<Int> = + conflatedCallbackFlow { + val listener = FocusTransitionListener { displayId -> trySend(displayId) } + transitions.setFocusTransitionListener(listener, mainExecutor) + awaitClose { transitions.unsetFocusTransitionListener(listener) } + } + .onEach { + logBuffer.log( + "FocusedDisplayRepository", + LogLevel.INFO, + { str1 = it.toString() }, + { "Newly focused display: $str1" }, + ) + } + + /** Provides the currently focused display. */ + val focusedDisplayId: StateFlow<Int> + get() = focusedTask.stateIn(scope, SharingStarted.Eagerly, DEFAULT_DISPLAY) +} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepStaticInitializer.java b/packages/SystemUI/src/com/android/systemui/log/dagger/FocusedDisplayRepoLog.kt index eeebee985e4a..302f9624b056 100644 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepStaticInitializer.java +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/FocusedDisplayRepoLog.kt @@ -13,21 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package android.ravenwood.annotation; -import static java.lang.annotation.ElementType.TYPE; +package com.android.systemui.log.dagger -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import javax.inject.Qualifier -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * @hide - */ -@Target(TYPE) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodKeepStaticInitializer { -} +/** A [com.android.systemui.log.LogBuffer] for display metrics related logging. */ +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class FocusedDisplayRepoLog diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java index 2053b53dba63..7ebdd88bfe6f 100644 --- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java @@ -655,6 +655,14 @@ public class LogModule { return factory.create("DisplayMetricsRepo", 50); } + /** Provides a {@link LogBuffer} for focus related logs. */ + @Provides + @SysUISingleton + @FocusedDisplayRepoLog + public static LogBuffer provideFocusedDisplayRepoLogBuffer(LogBufferFactory factory) { + return factory.create("FocusedDisplayRepo", 50); + } + /** Provides a {@link LogBuffer} for the scene framework. */ @Provides @SysUISingleton diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt index 222d783ab79a..4528b047375a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt @@ -417,6 +417,7 @@ class LegacyMediaDataManagerImpl( override fun onNotificationAdded(key: String, sbn: StatusBarNotification) { if (useQsMediaPlayer && isMediaNotification(sbn)) { var isNewlyActiveEntry = false + var isConvertingToActive = false Assert.isMainThread() val oldKey = findExistingEntry(key, sbn.packageName) if (oldKey == null) { @@ -433,9 +434,10 @@ class LegacyMediaDataManagerImpl( // Resume -> active conversion; move to new key val oldData = mediaEntries.remove(oldKey)!! isNewlyActiveEntry = true + isConvertingToActive = true mediaEntries.put(key, oldData) } - loadMediaData(key, sbn, oldKey, isNewlyActiveEntry) + loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } else { onNotificationRemoved(key) } @@ -535,10 +537,11 @@ class LegacyMediaDataManagerImpl( sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, + isConvertingToActive: Boolean = false, ) { if (Flags.mediaLoadMetadataViaMediaDataLoader()) { applicationScope.launch { - loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry) + loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } } else { backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) } @@ -550,10 +553,11 @@ class LegacyMediaDataManagerImpl( sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, + isConvertingToActive: Boolean = false, ) = withContext(backgroundDispatcher) { val lastActive = systemClock.elapsedRealtime() - val result = mediaDataLoader.get().loadMediaData(key, sbn) + val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive) if (result == null) { Log.d(TAG, "No result from loadMediaData") return@withContext diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt index 7b55dac8eee1..7b8703dfbe4f 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt @@ -111,16 +111,26 @@ constructor( * If a new [loadMediaData] is issued while existing load is in progress, the existing (old) * load will be cancelled. */ - suspend fun loadMediaData(key: String, sbn: StatusBarNotification): MediaDataLoaderResult? { - val loadMediaJob = backgroundScope.async { loadMediaDataInBackground(key, sbn) } + suspend fun loadMediaData( + key: String, + sbn: StatusBarNotification, + isConvertingToActive: Boolean = false, + ): MediaDataLoaderResult? { + val loadMediaJob = + backgroundScope.async { loadMediaDataInBackground(key, sbn, isConvertingToActive) } loadMediaJob.invokeOnCompletion { // We need to make sure we're removing THIS job after cancellation, not // a job that we created later. mediaProcessingJobs.remove(key, loadMediaJob) } - val existingJob = mediaProcessingJobs.put(key, loadMediaJob) + var existingJob: Job? = null + // Do not cancel loading jobs that convert resume players to active. + if (!isConvertingToActive) { + existingJob = mediaProcessingJobs.put(key, loadMediaJob) + existingJob?.cancel("New processing job incoming.") + } logD(TAG) { "Loading media data for $key... / existing job: $existingJob" } - existingJob?.cancel("New processing job incoming.") + return loadMediaJob.await() } @@ -129,12 +139,16 @@ constructor( private suspend fun loadMediaDataInBackground( key: String, sbn: StatusBarNotification, + isConvertingToActive: Boolean = false, ): MediaDataLoaderResult? = traceCoroutine("MediaDataLoader#loadMediaData") { // We have apps spamming us with quick notification updates which can cause // us to spend significant CPU time loading duplicate data. This debounces // those requests at the cost of a bit of latency. - delay(DEBOUNCE_DELAY_MS) + // No delay needed to load jobs converting resume players to active. + if (!isConvertingToActive) { + delay(DEBOUNCE_DELAY_MS) + } val token = sbn.notification.extras.getParcelable( diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt index fd7b6dcfebbc..affc7b741b2a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt @@ -330,6 +330,7 @@ class MediaDataProcessor( fun onNotificationAdded(key: String, sbn: StatusBarNotification) { if (useQsMediaPlayer && isMediaNotification(sbn)) { var isNewlyActiveEntry = false + var isConvertingToActive = false Assert.isMainThread() val oldKey = findExistingEntry(key, sbn.packageName) if (oldKey == null) { @@ -347,9 +348,10 @@ class MediaDataProcessor( // Resume -> active conversion; move to new key val oldData = mediaDataRepository.removeMediaEntry(oldKey)!! isNewlyActiveEntry = true + isConvertingToActive = true mediaDataRepository.addMediaEntry(key, oldData) } - loadMediaData(key, sbn, oldKey, isNewlyActiveEntry) + loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } else { onNotificationRemoved(key) } @@ -488,10 +490,11 @@ class MediaDataProcessor( sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, + isConvertingToActive: Boolean = false, ) { if (Flags.mediaLoadMetadataViaMediaDataLoader()) { applicationScope.launch { - loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry) + loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive) } } else { backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) } @@ -835,10 +838,11 @@ class MediaDataProcessor( sbn: StatusBarNotification, oldKey: String?, isNewlyActiveEntry: Boolean = false, + isConvertingToActive: Boolean = false, ) = withContext(backgroundDispatcher) { val lastActive = systemClock.elapsedRealtime() - val result = mediaDataLoader.get().loadMediaData(key, sbn) + val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive) if (result == null) { Log.d(TAG, "No result from loadMediaData") return@withContext diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt index 078d534833d1..f563f875a29f 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt @@ -26,18 +26,11 @@ import javax.inject.Inject /** A logger for all events related to the media tap-to-transfer receiver experience. */ @SysUISingleton -class MediaTttReceiverLogger -@Inject -constructor( - @MediaTttReceiverLogBuffer buffer: LogBuffer, -) : TemporaryViewLogger<ChipReceiverInfo>(buffer, TAG) { +class MediaTttReceiverLogger @Inject constructor(@MediaTttReceiverLogBuffer buffer: LogBuffer) : + TemporaryViewLogger<ChipReceiverInfo>(buffer, TAG) { /** Logs a change in the chip state for the given [mediaRouteId]. */ - fun logStateChange( - stateName: String, - mediaRouteId: String, - packageName: String?, - ) { + fun logStateChange(stateName: String, mediaRouteId: String, packageName: String?) { MediaTttLoggerUtils.logStateChange(buffer, TAG, stateName, mediaRouteId, packageName) } @@ -51,12 +44,27 @@ constructor( MediaTttLoggerUtils.logPackageNotFound(buffer, TAG, packageName) } - fun logRippleAnimationEnd(id: Int) { + fun logRippleAnimationEnd(id: Int, type: String) { buffer.log( tag, LogLevel.DEBUG, - { int1 = id }, - { "ripple animation for view with id: $int1 is ended" } + { + int1 = id + str1 = type + }, + { "ripple animation for view with id=$int1 is ended, animation type=$str1" }, + ) + } + + fun logRippleAnimationStart(id: Int, type: String) { + buffer.log( + tag, + LogLevel.DEBUG, + { + int1 = id + str1 = type + }, + { "ripple animation for view with id=$int1 is started, animation type=$str1" }, ) } diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt index a232971c4e3f..9d004352e893 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt @@ -69,7 +69,9 @@ constructor( ) rippleView.addOnAttachStateChangeListener( object : View.OnAttachStateChangeListener { - override fun onViewDetachedFromWindow(view: View) {} + override fun onViewDetachedFromWindow(view: View) { + view.visibility = View.GONE + } override fun onViewAttachedToWindow(view: View) { if (view == null) { @@ -81,7 +83,7 @@ constructor( } else { layoutRipple(attachedRippleView) } - attachedRippleView.expandRipple() + attachedRippleView.expandRipple(mediaTttReceiverLogger) attachedRippleView.removeOnAttachStateChangeListener(this) } } @@ -126,7 +128,7 @@ constructor( iconRippleView.setMaxSize(radius * 0.8f, radius * 0.8f) iconRippleView.setCenter( width * 0.5f, - height - getReceiverIconSize() * 0.5f - getReceiverIconBottomMargin() + height - getReceiverIconSize() * 0.5f - getReceiverIconBottomMargin(), ) iconRippleView.setColor(getRippleColor(), RIPPLE_OPACITY) } diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt index 81059e31f3c1..cd733ec7bf94 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt @@ -37,10 +37,14 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi isStarted = false } - fun expandRipple(onAnimationEnd: Runnable? = null) { + fun expandRipple(logger: MediaTttReceiverLogger, onAnimationEnd: Runnable? = null) { duration = DEFAULT_DURATION isStarted = true - super.startRipple(onAnimationEnd) + super.startRipple { + logger.logRippleAnimationEnd(id, EXPAND) + onAnimationEnd?.run() + } + logger.logRippleAnimationStart(id, EXPAND) } /** Used to animate out the ripple. No-op if the ripple was never started via [startRipple]. */ @@ -53,10 +57,14 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi animator.removeAllListeners() animator.addListener( object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + onAnimationEnd(animation) + } + override fun onAnimationEnd(animation: Animator) { animation?.let { visibility = GONE - logger.logRippleAnimationEnd(id) + logger.logRippleAnimationEnd(id, COLLAPSE) } onAnimationEnd?.run() isStarted = false @@ -64,13 +72,14 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi } ) animator.reverse() + logger.logRippleAnimationStart(id, COLLAPSE) } // Expands the ripple to cover full screen. fun expandToFull( newHeight: Float, logger: MediaTttReceiverLogger, - onAnimationEnd: Runnable? = null + onAnimationEnd: Runnable? = null, ) { if (!isStarted) { return @@ -95,10 +104,14 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi } animator.addListener( object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + onAnimationEnd(animation) + } + override fun onAnimationEnd(animation: Animator) { animation?.let { visibility = GONE - logger.logRippleAnimationEnd(id) + logger.logRippleAnimationEnd(id, EXPAND_TO_FULL) } onAnimationEnd?.run() isStarted = false @@ -106,6 +119,7 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi } ) animator.start() + logger.logRippleAnimationStart(id, EXPAND_TO_FULL) } // Calculates the actual starting percentage according to ripple shader progress set method. @@ -151,5 +165,8 @@ class ReceiverChipRippleView(context: Context?, attrs: AttributeSet?) : RippleVi companion object { const val DEFAULT_DURATION = 333L const val EXPAND_TO_FULL_DURATION = 1000L + private const val COLLAPSE = "collapse" + private const val EXPAND_TO_FULL = "expand to full" + private const val EXPAND = "expand" } } diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt index 4fdd90bdcded..b5d45a488997 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt @@ -4,10 +4,10 @@ import android.content.ComponentName import android.graphics.Bitmap import android.graphics.Insets import android.graphics.Rect -import android.net.Uri import android.os.Process import android.os.UserHandle import android.view.Display +import android.view.WindowManager import android.view.WindowManager.ScreenshotSource import android.view.WindowManager.ScreenshotType import androidx.annotation.VisibleForTesting @@ -26,11 +26,9 @@ data class ScreenshotData( var insets: Insets, var bitmap: Bitmap?, var displayId: Int, - /** App-provided URL representing the content the user was looking at in the screenshot. */ - var contextUrl: Uri? = null, ) { - val packageNameString: String - get() = if (topComponent == null) "" else topComponent!!.packageName + val packageNameString + get() = topComponent?.packageName ?: "" fun getUserOrDefault(): UserHandle { return userHandle ?: Process.myUserHandle() @@ -54,8 +52,8 @@ data class ScreenshotData( @VisibleForTesting fun forTesting() = ScreenshotData( - type = 0, - source = 0, + type = WindowManager.TAKE_SCREENSHOT_FULLSCREEN, + source = ScreenshotSource.SCREENSHOT_KEY_CHORD, userHandle = null, topComponent = null, screenBounds = null, diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt b/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt index 448f7c4d7e95..38608d0e793a 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt +++ b/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt @@ -20,9 +20,11 @@ import android.net.Uri import android.os.Trace import android.util.Log import android.view.Display +import android.view.WindowManager.ScreenshotSource import android.view.WindowManager.TAKE_SCREENSHOT_PROVIDED_IMAGE import com.android.internal.logging.UiEventLogger import com.android.internal.util.ScreenshotRequest +import com.android.systemui.Flags.screenshotMultidisplayFocusChange import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.display.data.repository.DisplayRepository @@ -40,7 +42,7 @@ interface TakeScreenshotExecutor { suspend fun executeScreenshots( screenshotRequest: ScreenshotRequest, onSaved: (Uri?) -> Unit, - requestCallback: RequestCallback + requestCallback: RequestCallback, ) fun onCloseSystemDialogsReceived() @@ -52,7 +54,7 @@ interface TakeScreenshotExecutor { fun executeScreenshotsAsync( screenshotRequest: ScreenshotRequest, onSaved: Consumer<Uri?>, - requestCallback: RequestCallback + requestCallback: RequestCallback, ) } @@ -60,7 +62,7 @@ interface ScreenshotHandler { fun handleScreenshot( screenshot: ScreenshotData, finisher: Consumer<Uri?>, - requestCallback: RequestCallback + requestCallback: RequestCallback, ) } @@ -75,7 +77,7 @@ class TakeScreenshotExecutorImpl @Inject constructor( private val interactiveScreenshotHandlerFactory: InteractiveScreenshotHandler.Factory, - displayRepository: DisplayRepository, + private val displayRepository: DisplayRepository, @Application private val mainScope: CoroutineScope, private val screenshotRequestProcessor: ScreenshotRequestProcessor, private val uiEventLogger: UiEventLogger, @@ -95,31 +97,44 @@ constructor( override suspend fun executeScreenshots( screenshotRequest: ScreenshotRequest, onSaved: (Uri?) -> Unit, - requestCallback: RequestCallback + requestCallback: RequestCallback, ) { - val displays = getDisplaysToScreenshot(screenshotRequest.type) - val resultCallbackWrapper = MultiResultCallbackWrapper(requestCallback) - if (displays.isEmpty()) { - Log.wtf(TAG, "No displays found for screenshot.") - } - displays.forEach { display -> - val displayId = display.displayId - var screenshotHandler: ScreenshotHandler = - if (displayId == Display.DEFAULT_DISPLAY) { - getScreenshotController(display) - } else { - headlessScreenshotHandler - } - Log.d(TAG, "Executing screenshot for display $displayId") + if (screenshotMultidisplayFocusChange()) { + val display = getDisplayToScreenshot(screenshotRequest) + val screenshotHandler = getScreenshotController(display) dispatchToController( screenshotHandler, - rawScreenshotData = ScreenshotData.fromRequest(screenshotRequest, displayId), - onSaved = - if (displayId == Display.DEFAULT_DISPLAY) { - onSaved - } else { _ -> }, - callback = resultCallbackWrapper.createCallbackForId(displayId) + ScreenshotData.fromRequest(screenshotRequest, display.displayId), + onSaved, + requestCallback, ) + } else { + val displays = getDisplaysToScreenshot(screenshotRequest.type) + val resultCallbackWrapper = MultiResultCallbackWrapper(requestCallback) + if (displays.isEmpty()) { + Log.e(TAG, "No displays found for screenshot.") + } + + displays.forEach { display -> + val displayId = display.displayId + var screenshotHandler: ScreenshotHandler = + if (displayId == Display.DEFAULT_DISPLAY) { + getScreenshotController(display) + } else { + headlessScreenshotHandler + } + + Log.d(TAG, "Executing screenshot for display $displayId") + dispatchToController( + screenshotHandler, + rawScreenshotData = ScreenshotData.fromRequest(screenshotRequest, displayId), + onSaved = + if (displayId == Display.DEFAULT_DISPLAY) { + onSaved + } else { _ -> }, + callback = resultCallbackWrapper.createCallbackForId(displayId), + ) + } } } @@ -128,7 +143,7 @@ constructor( screenshotHandler: ScreenshotHandler, rawScreenshotData: ScreenshotData, onSaved: (Uri?) -> Unit, - callback: RequestCallback + callback: RequestCallback, ) { // Let's wait before logging "screenshot requested", as we should log the processed // ScreenshotData. @@ -160,13 +175,13 @@ constructor( uiEventLogger.log( ScreenshotEvent.getScreenshotSource(screenshotData.source), 0, - screenshotData.packageNameString + screenshotData.packageNameString, ) } private fun onFailedScreenshotRequest( screenshotData: ScreenshotData, - callback: RequestCallback + callback: RequestCallback, ) { uiEventLogger.log(SCREENSHOT_CAPTURE_FAILED, 0, screenshotData.packageNameString) getNotificationController(screenshotData.displayId) @@ -184,6 +199,31 @@ constructor( } } + // Return the single display to be screenshot based upon the request. + private suspend fun getDisplayToScreenshot(screenshotRequest: ScreenshotRequest): Display { + return when (screenshotRequest.source) { + // TODO(b/367394043): Overview requests should use a display ID provided in + // ScreenshotRequest. + ScreenshotSource.SCREENSHOT_OVERVIEW -> + displayRepository.getDisplay(Display.DEFAULT_DISPLAY) + ?: error("Can't find default display") + + // Key chord and vendor gesture occur on the device itself, so screenshot the device's + // display + ScreenshotSource.SCREENSHOT_KEY_CHORD, + ScreenshotSource.SCREENSHOT_VENDOR_GESTURE -> + displayRepository.getDisplay(Display.DEFAULT_DISPLAY) + ?: error("Can't find default display") + + // All other invocations use the focused display + else -> focusedDisplay() + } + } + + // TODO(b/367394043): Determine the focused display here. + private suspend fun focusedDisplay() = + displayRepository.getDisplay(Display.DEFAULT_DISPLAY) ?: error("Can't find default display") + /** Propagates the close system dialog signal to the ScreenshotController. */ override fun onCloseSystemDialogsReceived() { if (screenshotController?.isPendingSharedTransition() == false) { @@ -214,7 +254,7 @@ constructor( override fun executeScreenshotsAsync( screenshotRequest: ScreenshotRequest, onSaved: Consumer<Uri?>, - requestCallback: RequestCallback + requestCallback: RequestCallback, ) { mainScope.launch { executeScreenshots(screenshotRequest, { uri -> onSaved.accept(uri) }, requestCallback) @@ -235,9 +275,7 @@ constructor( * - If any finished with an error, [reportError] of [originalCallback] is called * - Otherwise, [onFinish] is called. */ - private class MultiResultCallbackWrapper( - private val originalCallback: RequestCallback, - ) { + private class MultiResultCallbackWrapper(private val originalCallback: RequestCallback) { private val idsPending = mutableSetOf<Int>() private val idsWithErrors = mutableSetOf<Int>() @@ -290,7 +328,7 @@ constructor( Display.TYPE_EXTERNAL, Display.TYPE_INTERNAL, Display.TYPE_OVERLAY, - Display.TYPE_WIFI + Display.TYPE_WIFI, ) } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java index 1ea26e5727ac..5ae24f76a9bf 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java @@ -63,6 +63,7 @@ import com.android.systemui.bouncer.domain.interactor.PrimaryBouncerCallbackInte import com.android.systemui.bouncer.domain.interactor.PrimaryBouncerInteractor; import com.android.systemui.bouncer.shared.flag.ComposeBouncerFlags; import com.android.systemui.bouncer.ui.BouncerView; +import com.android.systemui.bouncer.util.BouncerTestUtilsKt; import com.android.systemui.dagger.SysUISingleton; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor; @@ -1552,8 +1553,10 @@ public class StatusBarKeyguardViewManager implements RemoteInputController.Callb } public boolean shouldDismissOnMenuPressed() { - return mPrimaryBouncerView.getDelegate() != null - && mPrimaryBouncerView.getDelegate().shouldDismissOnMenuPressed(); + return (mPrimaryBouncerView.getDelegate() != null + && mPrimaryBouncerView.getDelegate().shouldDismissOnMenuPressed()) || ( + ComposeBouncerFlags.INSTANCE.isEnabled() && BouncerTestUtilsKt.shouldEnableMenuKey( + mContext.getResources())); } public boolean interceptMediaKey(KeyEvent event) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt index a2959811cd0a..15705fbfec33 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt @@ -3,6 +3,8 @@ package com.android.systemui.screenshot import android.content.ComponentName import android.graphics.Bitmap import android.net.Uri +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags import android.view.Display import android.view.Display.TYPE_EXTERNAL import android.view.Display.TYPE_INTERNAL @@ -15,6 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.testing.UiEventLoggerFake import com.android.internal.util.ScreenshotRequest +import com.android.systemui.Flags import com.android.systemui.SysuiTestCase import com.android.systemui.display.data.repository.FakeDisplayRepository import com.android.systemui.display.data.repository.display @@ -75,6 +78,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_severalDisplays_callsControllerForEachOne() = testScope.runTest { val internalDisplay = display(TYPE_INTERNAL, id = 0) @@ -106,6 +110,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_providedImageType_callsOnlyDefaultDisplayController() = testScope.runTest { val internalDisplay = display(TYPE_INTERNAL, id = 0) @@ -115,7 +120,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { screenshotExecutor.executeScreenshots( createScreenshotRequest(TAKE_SCREENSHOT_PROVIDED_IMAGE), onSaved, - callback + callback, ) verify(controllerFactory).create(eq(internalDisplay)) @@ -137,6 +142,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_onlyVirtualDisplays_noInteractionsWithControllers() = testScope.runTest { setDisplays(display(TYPE_VIRTUAL, id = 0), display(TYPE_VIRTUAL, id = 1)) @@ -149,6 +155,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_allowedTypes_allCaptured() = testScope.runTest { whenever(controllerFactory.create(any())).thenReturn(controller) @@ -157,7 +164,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1), display(TYPE_OVERLAY, id = 2), - display(TYPE_WIFI, id = 3) + display(TYPE_WIFI, id = 3), ) val onSaved = { _: Uri? -> } screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback) @@ -168,6 +175,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_reportsOnFinishedOnlyWhenBothFinished() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) @@ -193,6 +201,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_oneFinishesOtherFails_reportFailsOnlyAtTheEnd() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) @@ -220,6 +229,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_allDisplaysFail_reportsFail() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) @@ -319,6 +329,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_errorFromProcessor_logsScreenshotRequested() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) @@ -336,6 +347,7 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) fun executeScreenshots_errorFromProcessor_logsUiError() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) @@ -379,7 +391,8 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test - fun executeScreenshots_errorFromScreenshotController_reportsRequested() = + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_multidisplay_reportsRequested() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) val onSaved = { _: Uri? -> } @@ -399,7 +412,27 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test - fun executeScreenshots_errorFromScreenshotController_reportsError() = + @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_reportsRequested() = + testScope.runTest { + setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) + val onSaved = { _: Uri? -> } + whenever(controller.handleScreenshot(any(), any(), any())) + .thenThrow(IllegalStateException::class.java) + + screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback) + + val screenshotRequested = + eventLogger.logs.filter { + it.eventId == ScreenshotEvent.SCREENSHOT_REQUESTED_KEY_OTHER.id + } + assertThat(screenshotRequested).hasSize(1) + screenshotExecutor.onDestroy() + } + + @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_multidisplay_reportsError() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) val onSaved = { _: Uri? -> } @@ -419,7 +452,27 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test - fun executeScreenshots_errorFromScreenshotController_showsErrorNotification() = + @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_reportsError() = + testScope.runTest { + setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) + val onSaved = { _: Uri? -> } + whenever(controller.handleScreenshot(any(), any(), any())) + .thenThrow(IllegalStateException::class.java) + + screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback) + + val screenshotRequested = + eventLogger.logs.filter { + it.eventId == ScreenshotEvent.SCREENSHOT_CAPTURE_FAILED.id + } + assertThat(screenshotRequested).hasSize(1) + screenshotExecutor.onDestroy() + } + + @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_multidisplay_showsErrorNotification() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) val onSaved = { _: Uri? -> } @@ -436,6 +489,21 @@ class TakeScreenshotExecutorTest : SysuiTestCase() { } @Test + @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE) + fun executeScreenshots_errorFromScreenshotController_showsErrorNotification() = + testScope.runTest { + setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1)) + val onSaved = { _: Uri? -> } + whenever(controller.handleScreenshot(any(), any(), any())) + .thenThrow(IllegalStateException::class.java) + + screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback) + + verify(notificationsController0).notifyScreenshotError(any()) + screenshotExecutor.onDestroy() + } + + @Test fun executeScreenshots_finisherCalledWithNullUri_succeeds() = testScope.runTest { setDisplays(display(TYPE_INTERNAL, id = 0)) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt new file mode 100644 index 000000000000..e4c793dae950 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.android.systemui.util + +import android.testing.UiThreadTest +import org.junit.Assert.fail +import org.junit.rules.MethodRule +import org.junit.runners.model.FrameworkMethod +import org.junit.runners.model.Statement + +/** + * A Test rule which prevents us from using the UiThreadTest annotation. See + * go/android_junit4_uithreadtest (b/352170965) + */ +public class NoUiThreadTestRule : MethodRule { + override fun apply(base: Statement, method: FrameworkMethod, target: Any): Statement? { + if (hasUiThreadAnnotation(method, target)) { + fail("UiThreadTest doesn't actually run on the UiThread") + } + return base + } + + private fun hasUiThreadAnnotation(method: FrameworkMethod, target: Any): Boolean { + if (method.getAnnotation(UiThreadTest::class.java) != null) { + return true + } else { + return target.javaClass.getAnnotation(UiThreadTest::class.java) != null + } + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt new file mode 100644 index 000000000000..70dd10384db6 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package com.android.systemui.util + +import android.testing.UiThreadTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import java.lang.AssertionError +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.model.FrameworkMethod +import org.junit.runners.model.Statement + +/** + * Test that NoUiThreadTestRule asserts when it finds a framework method with a UiThreadTest + * annotation. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +public class NoUiThreadTestRuleTest : SysuiTestCase() { + + class TestStatement : Statement() { + override fun evaluate() {} + } + + inner class TestInner { + @Test @UiThreadTest fun simpleUiTest() {} + + @Test fun simpleTest() {} + } + + /** + * Test that NoUiThreadTestRule throws an asserts false if a test is annotated + * with @UiThreadTest + */ + @Test(expected = AssertionError::class) + fun testNoUiThreadFail() { + val method = TestInner::class.java.getDeclaredMethod("simpleUiTest") + val frameworkMethod = FrameworkMethod(method) + val noUiThreadTestRule = NoUiThreadTestRule() + val testStatement = TestStatement() + // target needs to be non-null + val obj = Object() + noUiThreadTestRule.apply(testStatement, frameworkMethod, obj) + } + + /** + * Test that NoUiThreadTestRule throws an asserts false if a test is annotated + * with @UiThreadTest + */ + fun testNoUiThreadOK() { + val method = TestInner::class.java.getDeclaredMethod("simpleUiTest") + val frameworkMethod = FrameworkMethod(method) + val noUiThreadTestRule = NoUiThreadTestRule() + val testStatement = TestStatement() + + // because target needs to be non-null + val obj = Object() + val newStatement = noUiThreadTestRule.apply(testStatement, frameworkMethod, obj) + Assert.assertEquals(newStatement, testStatement) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java new file mode 100644 index 000000000000..f81b7de86d00 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.util; + +import android.os.Looper; +import android.util.Log; + +import androidx.test.platform.app.InstrumentationRegistry; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * A class to launch runnables on the UI thread explicitly. + */ +public class UiThread { + private static final String TAG = "UiThread"; + + /** + * Run a runnable on the UI thread using instrumentation.runOnMainSync. + * + * @param runnable code to run on the UI thread. + * @throws Throwable if the code threw an exception, so it can be reported + * to the test. + */ + public static void runOnUiThread(final Runnable runnable) throws Throwable { + if (Looper.myLooper() == Looper.getMainLooper()) { + Log.w( + TAG, + "UiThread.runOnUiThread() should not be called from the " + + "main application thread"); + runnable.run(); + } else { + FutureTask<Void> task = new FutureTask<>(runnable, null); + InstrumentationRegistry.getInstrumentation().runOnMainSync(task); + try { + task.get(); + } catch (ExecutionException e) { + // Expose the original exception + throw e.getCause(); + } + } + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java new file mode 100644 index 000000000000..abf2e8d04d7e --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.util; + +import android.os.Looper; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + + +/** + * Test that UiThread.runOnUiThread() actually runs on the UI Thread. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class UiThreadRunTest extends SysuiTestCase { + + @Test + public void testUiThread() throws Throwable { + UiThread.runOnUiThread(() -> { + Assert.assertEquals(Looper.getMainLooper().getThread(), Thread.currentThread()); + }); + } +} diff --git a/ravenwood/Android.bp b/ravenwood/Android.bp index 6b1197ad4c18..11b66fc3f1e5 100644 --- a/ravenwood/Android.bp +++ b/ravenwood/Android.bp @@ -12,15 +12,6 @@ package { } filegroup { - name: "ravenwood-annotations", - srcs: [ - "annotations-src/**/*.java", - ], - path: "annotations-src", - visibility: ["//visibility:public"], -} - -filegroup { name: "ravenwood-services-policies", srcs: [ "texts/ravenwood-services-policies.txt", @@ -52,14 +43,6 @@ filegroup { visibility: ["//visibility:public"], } -java_library { - name: "ravenwood-annotations-lib", - srcs: [":ravenwood-annotations"], - sdk_version: "core_current", - host_supported: true, - visibility: ["//visibility:public"], -} - // This and the next module contain the same classes with different implementations. // "ravenwood-runtime-common-device" will be statically linked in device side tests. // "ravenwood-runtime-common-ravenwood" will only exist in ravenwood-runtime, which will take diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodClassLoadHook.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodClassLoadHook.java deleted file mode 100644 index 7a3142b041d1..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodClassLoadHook.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.TYPE; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * Add this with a fully-specified method name (e.g. {@code "com.package.Class.methodName"}) - * of a callback to get a callback at the class load time. - * - * The method must be {@code public static} with a single argument that takes {@link Class}. - * - * Typically, this is used with {@link #LIBANDROID_LOADING_HOOK}, which will load the necessary - * native libraries. - * - * @hide - */ -@Target({TYPE}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodClassLoadHook { - String value(); - - /** - * Class load hook that loads <code>libandroid_runtime</code>. - */ - public static String LIBANDROID_LOADING_HOOK - = "com.android.platform.test.ravenwood.runtimehelper.ClassLoadHook.onClassLoaded"; -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeep.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeep.java deleted file mode 100644 index f02f06c056bd..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeep.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.CONSTRUCTOR; -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target({FIELD, METHOD, CONSTRUCTOR}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodKeep { -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepPartialClass.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepPartialClass.java deleted file mode 100644 index 784727410188..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepPartialClass.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodKeepPartialClass { -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepWholeClass.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepWholeClass.java deleted file mode 100644 index d2c77c1b8566..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodKeepWholeClass.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.CONSTRUCTOR; -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.TYPE; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * TODO: Create "whole-class-throw"? - * - * @hide - */ -@Target({TYPE, FIELD, METHOD, CONSTRUCTOR}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodKeepWholeClass { -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRedirectionClass.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRedirectionClass.java deleted file mode 100644 index bee9222ae5eb..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRedirectionClass.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.TYPE; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target({TYPE}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodRedirectionClass { - String value(); -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRemove.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRemove.java deleted file mode 100644 index b69c63748d81..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodRemove.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.CONSTRUCTOR; -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.TYPE; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target({TYPE, FIELD, METHOD, CONSTRUCTOR}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodRemove { - /** - * One or more classes that aren't yet supported by Ravenwood, which is why this method throws. - */ - Class<?>[] blockedBy() default {}; - - /** - * General free-form description of why this method throws. - */ - String reason() default ""; - - /** - * Tracking bug number, if any. - */ - long bug() default 0; -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodReplace.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodReplace.java deleted file mode 100644 index 57cdfd2240d0..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodReplace.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.METHOD; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * - * @hide - */ -@Target({METHOD}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodReplace { - /** - * One or more classes that aren't yet supported by Ravenwood, which is why this method is - * being replaced. - */ - Class<?>[] blockedBy() default {}; - - /** - * General free-form description of why this method is being replaced. - */ - String reason() default ""; - - /** - * Tracking bug number, if any. - */ - long bug() default 0; -} diff --git a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodThrow.java b/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodThrow.java deleted file mode 100644 index 19e6af1c478d..000000000000 --- a/ravenwood/annotations-src/android/ravenwood/annotation/RavenwoodThrow.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package android.ravenwood.annotation; - -import static java.lang.annotation.ElementType.CONSTRUCTOR; -import static java.lang.annotation.ElementType.METHOD; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * THIS ANNOTATION IS EXPERIMENTAL. REACH OUT TO g/ravenwood BEFORE USING IT, OR YOU HAVE ANY - * QUESTIONS ABOUT IT. - * - * TODO: Javadoc - * TODO: Create "whole-class-throw"? - * - * @hide - */ -@Target({METHOD, CONSTRUCTOR}) -@Retention(RetentionPolicy.CLASS) -public @interface RavenwoodThrow { - /** - * One or more classes that aren't yet supported by Ravenwood, which is why this method throws. - */ - Class<?>[] blockedBy() default {}; - - /** - * General free-form description of why this method throws. - */ - String reason() default ""; - - /** - * Tracking bug number, if any. - */ - long bug() default 0; -} diff --git a/services/companion/java/com/android/server/companion/securechannel/SecureChannel.java b/services/companion/java/com/android/server/companion/securechannel/SecureChannel.java index 71a182225013..dbeca82ade89 100644 --- a/services/companion/java/com/android/server/companion/securechannel/SecureChannel.java +++ b/services/companion/java/com/android/server/companion/securechannel/SecureChannel.java @@ -16,8 +16,6 @@ package com.android.server.companion.securechannel; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_SUCCESS; - import android.annotation.NonNull; import android.content.Context; import android.os.Build; @@ -498,7 +496,7 @@ public class SecureChannel { private void exchangeAttestation() throws IOException, GeneralSecurityException, BadHandleException, CryptoException { - if (mVerificationResult == RESULT_SUCCESS) { + if (mVerificationResult == 0) { Slog.d(TAG, "Remote attestation was already verified."); return; } @@ -530,11 +528,11 @@ public class SecureChannel { sendMessage(MessageType.AVF_RESULT, verificationResult); byte[] remoteVerificationResult = readMessage(MessageType.AVF_RESULT); - if (ByteBuffer.wrap(remoteVerificationResult).getInt() != RESULT_SUCCESS) { + if (ByteBuffer.wrap(remoteVerificationResult).getInt() != 0) { throw new SecureChannelException("Remote device failed to verify local attestation."); } - if (mVerificationResult != RESULT_SUCCESS) { + if (mVerificationResult != 0) { throw new SecureChannelException("Failed to verify remote attestation."); } @@ -549,7 +547,7 @@ public class SecureChannel { return false; } // Is authenticated - return mPskVerified || mVerificationResult == RESULT_SUCCESS; + return mPskVerified || mVerificationResult == 0; } // First byte indicates message type; 0 = CLIENT INIT, 1 = SERVER INIT diff --git a/services/core/java/com/android/server/StorageManagerService.java b/services/core/java/com/android/server/StorageManagerService.java index e64a4803b14f..9d27731cecd4 100644 --- a/services/core/java/com/android/server/StorageManagerService.java +++ b/services/core/java/com/android/server/StorageManagerService.java @@ -2751,7 +2751,8 @@ class StorageManagerService extends IStorageManager.Stub boolean smartIdleMaintEnabled = DeviceConfig.getBoolean( DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, "smart_idle_maint_enabled", - DEFAULT_SMART_IDLE_MAINT_ENABLED); + DEFAULT_SMART_IDLE_MAINT_ENABLED) + && !SystemProperties.getBoolean("ro.boot.zufs_provisioned", false); if (smartIdleMaintEnabled) { mLifetimePercentThreshold = DeviceConfig.getInt( DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java index 3499a3a5edde..0ca3b56486e3 100644 --- a/services/core/java/com/android/server/accounts/AccountManagerService.java +++ b/services/core/java/com/android/server/accounts/AccountManagerService.java @@ -5062,6 +5062,8 @@ public class AccountManagerService Log.e(TAG, String.format(tmpl, activityName, pkgName, mAccountType)); return false; } + intent.setComponent(targetActivityInfo.getComponentName()); + bundle.putParcelable(AccountManager.KEY_INTENT, intent); return true; } finally { Binder.restoreCallingIdentity(bid); @@ -5083,14 +5085,15 @@ public class AccountManagerService Bundle simulateBundle = p.readBundle(); p.recycle(); Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT, Intent.class); - if (intent != null && intent.getClass() != Intent.class) { - return false; - } Intent simulateIntent = simulateBundle.getParcelable(AccountManager.KEY_INTENT, Intent.class); if (intent == null) { return (simulateIntent == null); } + if (intent.getClass() != Intent.class || simulateIntent.getClass() != Intent.class) { + return false; + } + if (!intent.filterEquals(simulateIntent)) { return false; } diff --git a/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java b/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java index 91a4d6f1707f..598901e33305 100644 --- a/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java +++ b/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java @@ -764,6 +764,7 @@ public class ContextHubClientBroker extends IContextHubClient.Stub boolean hasPermissions(List<String> permissions) { for (String permission : permissions) { if (mContext.checkPermission(permission, mPid, mUid) != PERMISSION_GRANTED) { + Log.e(TAG, "no permission for " + permission); return false; } } @@ -919,6 +920,14 @@ public class ContextHubClientBroker extends IContextHubClient.Stub } } if (curAuthState != newAuthState) { + if (newAuthState == AUTHORIZATION_DENIED + || newAuthState == AUTHORIZATION_DENIED_GRACE_PERIOD) { + Log.e(TAG, "updateNanoAppAuthState auth error: " + + Long.toHexString(nanoAppId) + ", " + + nanoappPermissions + ", " + + gracePeriodExpired + ", " + + forceDenied); + } // Don't send the callback in the synchronized block or it could end up in a deadlock. sendAuthStateCallback(nanoAppId, newAuthState); } diff --git a/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java index 539c41537f8e..d7aa9876fe0d 100644 --- a/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java @@ -30,6 +30,7 @@ import com.android.internal.os.Clock; import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.BluetoothPowerStatsLayout; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -142,6 +143,7 @@ public class BluetoothPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mDeviceStats, 0); mPowerStats.uidStats.clear(); collectBluetoothActivityInfo(); diff --git a/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java index 128f14a31898..dd6484dad812 100644 --- a/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java @@ -34,6 +34,7 @@ import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.CpuPowerStatsLayout; import java.io.PrintWriter; +import java.util.Arrays; import java.util.Locale; /** @@ -330,7 +331,9 @@ public class CpuPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mCpuPowerStats.stats, 0); mCpuPowerStats.uidStats.clear(); + // TODO(b/305120724): additionally retrieve time-in-cluster for each CPU cluster long newTimestampNanos = mKernelCpuStatsReader.readCpuStats(this::processUidStats, mLayout.getScalingStepToPowerBracketMap(), mLastUpdateTimestampNanos, diff --git a/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java index 1d2e38849708..079fc3c026f9 100644 --- a/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java @@ -24,6 +24,8 @@ import com.android.internal.os.Clock; import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.EnergyConsumerPowerStatsLayout; +import java.util.Arrays; + public class EnergyConsumerPowerStatsCollector extends PowerStatsCollector { public interface Injector { @@ -105,6 +107,7 @@ public class EnergyConsumerPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mPowerStats.stats, 0); mPowerStats.uidStats.clear(); if (!mConsumedEnergyHelper.collectConsumedEnergy(mPowerStats, mLayout)) { diff --git a/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java index 8371e6681747..c38904fe2873 100644 --- a/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java @@ -27,6 +27,8 @@ import com.android.internal.os.Clock; import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.ScreenPowerStatsLayout; +import java.util.Arrays; + public class ScreenPowerStatsCollector extends PowerStatsCollector { private static final String TAG = "ScreenPowerStatsCollector"; @@ -115,6 +117,9 @@ public class ScreenPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mPowerStats.stats, 0); + mPowerStats.uidStats.clear(); + mConsumedEnergyHelper.collectConsumedEnergy(mPowerStats, mLayout); for (int display = 0; display < mDisplayCount; display++) { @@ -142,8 +147,6 @@ public class ScreenPowerStatsCollector extends PowerStatsCollector { mLastDozeTime[display] = screenDozeTimeMs; } - mPowerStats.uidStats.clear(); - mScreenUsageTimeRetriever.retrieveTopActivityTimes((uid, topActivityTimeMs) -> { long topActivityDuration = topActivityTimeMs - mLastTopActivityTime.get(uid); if (topActivityDuration == 0) { diff --git a/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java index 9e4a3911da0c..e36c9946531e 100644 --- a/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java @@ -25,6 +25,8 @@ import com.android.internal.os.Clock; import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.WakelockPowerStatsLayout; +import java.util.Arrays; + class WakelockPowerStatsCollector extends PowerStatsCollector { public interface WakelockDurationRetriever { @@ -89,6 +91,9 @@ class WakelockPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mPowerStats.stats, 0); + mPowerStats.uidStats.clear(); + long elapsedRealtime = mClock.elapsedRealtime(); mPowerStats.durationMs = elapsedRealtime - mLastCollectionTime; @@ -101,7 +106,6 @@ class WakelockPowerStatsCollector extends PowerStatsCollector { mLastWakelockDurationMs = wakelockDurationMillis; - mPowerStats.uidStats.clear(); mWakelockDurationRetriever.retrieveUidWakelockDuration((uid, durationMs) -> { if (!mFirstCollection) { long[] uidStats = mPowerStats.uidStats.get(uid); diff --git a/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java index 7a84b05823a4..1fdeac9816d0 100644 --- a/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java +++ b/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java @@ -30,6 +30,7 @@ import com.android.internal.os.Clock; import com.android.internal.os.PowerStats; import com.android.server.power.stats.format.WifiPowerStatsLayout; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -151,6 +152,9 @@ public class WifiPowerStatsCollector extends PowerStatsCollector { return null; } + Arrays.fill(mDeviceStats, 0); + mPowerStats.uidStats.clear(); + WifiActivityEnergyInfo activityInfo = null; if (mPowerReportingSupported) { activityInfo = collectWifiActivityInfo(); @@ -224,8 +228,6 @@ public class WifiPowerStatsCollector extends PowerStatsCollector { } private List<BatteryStatsImpl.NetworkStatsDelta> collectNetworkStats() { - mPowerStats.uidStats.clear(); - NetworkStats networkStats = mNetworkStatsSupplier.get(); if (networkStats == null) { return null; diff --git a/services/core/java/com/android/server/security/AttestationVerificationManagerService.java b/services/core/java/com/android/server/security/AttestationVerificationManagerService.java index 55f85ea27c82..22a359bced86 100644 --- a/services/core/java/com/android/server/security/AttestationVerificationManagerService.java +++ b/services/core/java/com/android/server/security/AttestationVerificationManagerService.java @@ -17,10 +17,10 @@ package com.android.server.security; import static android.Manifest.permission.USE_ATTESTATION_VERIFICATION_SERVICE; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_UNSUPPORTED_PROFILE; import static android.security.attestationverification.AttestationVerificationManager.PROFILE_PEER_DEVICE; import static android.security.attestationverification.AttestationVerificationManager.PROFILE_SELF_TRUSTED; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_UNKNOWN; import android.annotation.NonNull; import android.annotation.Nullable; @@ -88,8 +88,8 @@ public class AttestationVerificationManagerService extends SystemService { public void verifyToken(VerificationToken token, ParcelDuration parcelDuration, AndroidFuture resultCallback) throws RemoteException { enforceUsePermission(); - // TODO(b/201696614): Implement - resultCallback.complete(RESULT_UNKNOWN); + + throw new UnsupportedOperationException(); } private void enforceUsePermission() { @@ -123,9 +123,9 @@ public class AttestationVerificationManagerService extends SystemService { AttestationProfile profile, int localBindingType, Bundle requirements, byte[] attestation, AndroidFuture<IVerificationResult> resultCallback) { IVerificationResult result = new IVerificationResult(); - // TODO(b/201696614): Implement result.token = null; - switch (profile.getAttestationProfileId()) { + int profileId = profile.getAttestationProfileId(); + switch (profileId) { case PROFILE_SELF_TRUSTED: Slog.d(TAG, "Verifying Self Trusted profile."); try { @@ -133,7 +133,7 @@ public class AttestationVerificationManagerService extends SystemService { AttestationVerificationSelfTrustedVerifierForTesting.getInstance() .verifyAttestation(localBindingType, requirements, attestation); } catch (Throwable t) { - result.resultCode = RESULT_FAILURE; + result.resultCode = FLAG_FAILURE_CERTS; } break; case PROFILE_PEER_DEVICE: @@ -142,8 +142,8 @@ public class AttestationVerificationManagerService extends SystemService { localBindingType, requirements, attestation); break; default: - Slog.d(TAG, "No profile found, defaulting."); - result.resultCode = RESULT_UNKNOWN; + Slog.e(TAG, "Profile [" + profileId + "] is not supported."); + result.resultCode = FLAG_FAILURE_UNSUPPORTED_PROFILE; } resultCallback.complete(result); } diff --git a/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java b/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java index 41e3d00f3924..dc1f93664f79 100644 --- a/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java +++ b/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java @@ -16,11 +16,15 @@ package com.android.server.security; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_BOOT_STATE; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_KEYSTORE_REQUIREMENTS; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_PATCH_LEVEL_DIFF; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_UNKNOWN; import static android.security.attestationverification.AttestationVerificationManager.PARAM_CHALLENGE; import static android.security.attestationverification.AttestationVerificationManager.PARAM_MAX_PATCH_LEVEL_DIFF_MONTHS; import static android.security.attestationverification.AttestationVerificationManager.PARAM_PUBLIC_KEY; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_SUCCESS; import static android.security.attestationverification.AttestationVerificationManager.TYPE_CHALLENGE; import static android.security.attestationverification.AttestationVerificationManager.TYPE_PUBLIC_KEY; import static android.security.attestationverification.AttestationVerificationManager.localBindingTypeToString; @@ -35,10 +39,8 @@ import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; import android.os.Bundle; -import android.security.attestationverification.AttestationVerificationManager; import android.security.attestationverification.AttestationVerificationManager.LocalBindingType; import android.util.IndentingPrintWriter; -import android.util.Log; import android.util.Slog; import com.android.internal.R; @@ -100,7 +102,6 @@ import java.util.Set; */ class AttestationVerificationPeerDeviceVerifier { private static final String TAG = "AVF"; - private static final boolean DEBUG = Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.VERBOSE); private static final int MAX_PATCH_AGE_MONTHS = 12; /** @@ -188,32 +189,24 @@ class AttestationVerificationPeerDeviceVerifier { @NonNull byte[] attestation, @NonNull MyDumpData dumpData) { if (mCertificateFactory == null) { - debugVerboseLog("Unable to access CertificateFactory"); - return RESULT_FAILURE; + Slog.e(TAG, "Unable to access CertificateFactory"); + return FLAG_FAILURE_CERTS; } dumpData.mCertificationFactoryAvailable = true; if (mCertPathValidator == null) { - debugVerboseLog("Unable to access CertPathValidator"); - return RESULT_FAILURE; + Slog.e(TAG, "Unable to access CertPathValidator"); + return FLAG_FAILURE_CERTS; } dumpData.mCertPathValidatorAvailable = true; - - // Check if the provided local binding type is supported and if the provided requirements - // "match" the binding type. - if (!validateAttestationParameters(localBindingType, requirements)) { - return RESULT_FAILURE; - } - dumpData.mAttestationParametersOk = true; - // To provide the most information in the dump logs, we track the failure state but keep // verifying the rest of the attestation. For code safety, there are no transitions past - // here to set failed = false - boolean failed = false; + // here to set result = 0. + int result = 0; try { - // First: parse and validate the certificate chain. + // 1. parse and validate the certificate chain. final List<X509Certificate> certificateChain = getCertificates(attestation); // (returns void, but throws CertificateException and other similar Exceptions) validateCertificateChain(certificateChain); @@ -222,33 +215,38 @@ class AttestationVerificationPeerDeviceVerifier { final var leafCertificate = certificateChain.get(0); final var attestationExtension = fromCertificate(leafCertificate); - // Second: verify if the attestation satisfies the "peer device" profile. - if (!checkAttestationForPeerDeviceProfile(requirements, attestationExtension, - dumpData)) { - failed = true; + // 2. Check if the provided local binding type is supported and if the provided + // requirements "match" the binding type. + if (!validateAttestationParameters(localBindingType, requirements)) { + return FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; } + dumpData.mAttestationParametersOk = true; - // Third: check if the attestation satisfies local binding requirements. + // 3. check if the attestation satisfies local binding requirements. if (!checkLocalBindingRequirements( leafCertificate, attestationExtension, localBindingType, requirements, dumpData)) { - failed = true; + result |= FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; } + + // 4. verify if the attestation satisfies the "peer device" profile. + result |= checkAttestationForPeerDeviceProfile(requirements, attestationExtension, + dumpData); } catch (CertificateException | CertPathValidatorException | InvalidAlgorithmParameterException | IOException e) { - // Catch all non-RuntimeExpceptions (all of these are thrown by either getCertificates() + // Catch all non-RuntimeExceptions (all of these are thrown by either getCertificates() // or validateCertificateChain() or // AndroidKeystoreAttestationVerificationAttributes.fromCertificate()) - debugVerboseLog("Unable to parse/validate Android Attestation certificate(s)", e); - failed = true; + Slog.e(TAG, "Unable to parse/validate Android Attestation certificate(s)", e); + result = FLAG_FAILURE_CERTS; } catch (RuntimeException e) { - // Catch everyting else (RuntimeExpcetions), since we don't want to throw any exceptions - // out of this class/method. - debugVerboseLog("Unexpected error", e); - failed = true; + // Catch everything else (RuntimeExceptions), since we don't want to throw any + // exceptions out of this class/method. + Slog.e(TAG, "Unexpected error", e); + result = FLAG_FAILURE_UNKNOWN; } - return failed ? RESULT_FAILURE : RESULT_SUCCESS; + return result; } @NonNull @@ -270,22 +268,22 @@ class AttestationVerificationPeerDeviceVerifier { private boolean validateAttestationParameters( @LocalBindingType int localBindingType, @NonNull Bundle requirements) { if (localBindingType != TYPE_PUBLIC_KEY && localBindingType != TYPE_CHALLENGE) { - debugVerboseLog("Binding type is not supported: " + localBindingType); + Slog.e(TAG, "Binding type is not supported: " + localBindingType); return false; } if (requirements.size() < 1) { - debugVerboseLog("At least 1 requirement is required."); + Slog.e(TAG, "At least 1 requirement is required."); return false; } if (localBindingType == TYPE_PUBLIC_KEY && !requirements.containsKey(PARAM_PUBLIC_KEY)) { - debugVerboseLog("Requirements does not contain key: " + PARAM_PUBLIC_KEY); + Slog.e(TAG, "Requirements does not contain key: " + PARAM_PUBLIC_KEY); return false; } if (localBindingType == TYPE_CHALLENGE && !requirements.containsKey(PARAM_CHALLENGE)) { - debugVerboseLog("Requirements does not contain key: " + PARAM_CHALLENGE); + Slog.e(TAG, "Requirements does not contain key: " + PARAM_CHALLENGE); return false; } @@ -296,7 +294,7 @@ class AttestationVerificationPeerDeviceVerifier { throws CertificateException, CertPathValidatorException, InvalidAlgorithmParameterException { if (certificates.size() < 2) { - debugVerboseLog("Certificate chain less than 2 in size."); + Slog.e(TAG, "Certificate chain less than 2 in size."); throw new CertificateException("Certificate chain less than 2 in size."); } @@ -355,7 +353,7 @@ class AttestationVerificationPeerDeviceVerifier { final boolean publicKeyMatches = checkPublicKey( leafCertificate, requirements.getByteArray(PARAM_PUBLIC_KEY)); if (!publicKeyMatches) { - debugVerboseLog( + Slog.e(TAG, "Provided public key does not match leaf certificate public key."); return false; } @@ -366,7 +364,7 @@ class AttestationVerificationPeerDeviceVerifier { final boolean attestationChallengeMatches = checkAttestationChallenge( attestationAttributes, requirements.getByteArray(PARAM_CHALLENGE)); if (!attestationChallengeMatches) { - debugVerboseLog( + Slog.e(TAG, "Provided challenge does not match leaf certificate challenge."); return false; } @@ -386,7 +384,7 @@ class AttestationVerificationPeerDeviceVerifier { final boolean ownedBySystem = checkOwnedBySystem( leafCertificate, attestationAttributes); if (!ownedBySystem) { - debugVerboseLog("Certificate public key is not owned by the AndroidSystem."); + Slog.e(TAG, "Certificate public key is not owned by the AndroidSystem."); return false; } dumpData.mSystemOwned = true; @@ -401,67 +399,67 @@ class AttestationVerificationPeerDeviceVerifier { return true; } - private boolean checkAttestationForPeerDeviceProfile( + private int checkAttestationForPeerDeviceProfile( @NonNull Bundle requirements, @NonNull AndroidKeystoreAttestationVerificationAttributes attestationAttributes, MyDumpData dumpData) { - boolean result = true; + int result = 0; // Checks for support of Keymaster 4. if (attestationAttributes.getAttestationVersion() < 3) { - debugVerboseLog("Attestation version is not at least 3 (Keymaster 4)."); - result = false; + Slog.e(TAG, "Attestation version is not at least 3 (Keymaster 4)."); + result |= FLAG_FAILURE_KEYSTORE_REQUIREMENTS; } else { dumpData.mAttestationVersionAtLeast3 = true; } // Checks for support of Keymaster 4. if (attestationAttributes.getKeymasterVersion() < 4) { - debugVerboseLog("Keymaster version is not at least 4."); - result = false; + Slog.e(TAG, "Keymaster version is not at least 4."); + result |= FLAG_FAILURE_KEYSTORE_REQUIREMENTS; } else { dumpData.mKeymasterVersionAtLeast4 = true; } // First two characters are Android OS version. if (attestationAttributes.getKeyOsVersion() < 100000) { - debugVerboseLog("Android OS version is not 10+."); - result = false; + Slog.e(TAG, "Android OS version is not 10+."); + result |= FLAG_FAILURE_KEYSTORE_REQUIREMENTS; } else { dumpData.mOsVersionAtLeast10 = true; } if (!attestationAttributes.isAttestationHardwareBacked()) { - debugVerboseLog("Key is not HW backed."); - result = false; + Slog.e(TAG, "Key is not HW backed."); + result |= FLAG_FAILURE_KEYSTORE_REQUIREMENTS; } else { dumpData.mKeyHwBacked = true; } if (!attestationAttributes.isKeymasterHardwareBacked()) { - debugVerboseLog("Keymaster is not HW backed."); - result = false; + Slog.e(TAG, "Keymaster is not HW backed."); + result |= FLAG_FAILURE_KEYSTORE_REQUIREMENTS; } else { dumpData.mKeymasterHwBacked = true; } if (attestationAttributes.getVerifiedBootState() != VERIFIED) { - debugVerboseLog("Boot state not Verified."); - result = false; + Slog.e(TAG, "Boot state not Verified."); + result |= FLAG_FAILURE_BOOT_STATE; } else { dumpData.mBootStateIsVerified = true; } try { if (!attestationAttributes.isVerifiedBootLocked()) { - debugVerboseLog("Verified boot state is not locked."); - result = false; + Slog.e(TAG, "Verified boot state is not locked."); + result |= FLAG_FAILURE_BOOT_STATE; } else { dumpData.mVerifiedBootStateLocked = true; } } catch (IllegalStateException e) { - debugVerboseLog("VerifiedBootLocked is not set.", e); - result = false; + Slog.e(TAG, "VerifiedBootLocked is not set.", e); + result = FLAG_FAILURE_BOOT_STATE; } int maxPatchLevelDiffMonths = requirements.getInt(PARAM_MAX_PATCH_LEVEL_DIFF_MONTHS, @@ -470,8 +468,8 @@ class AttestationVerificationPeerDeviceVerifier { // Patch level integer YYYYMM is expected to be within maxPatchLevelDiffMonths of today. if (!isValidPatchLevel(attestationAttributes.getKeyOsPatchLevel(), maxPatchLevelDiffMonths)) { - debugVerboseLog("OS patch level is not within valid range."); - result = false; + Slog.e(TAG, "OS patch level is not within valid range."); + result |= FLAG_FAILURE_PATCH_LEVEL_DIFF; } else { dumpData.mOsPatchLevelInRange = true; } @@ -479,24 +477,24 @@ class AttestationVerificationPeerDeviceVerifier { // Patch level integer YYYYMMDD is expected to be within maxPatchLevelDiffMonths of today. if (!isValidPatchLevel(attestationAttributes.getKeyBootPatchLevel(), maxPatchLevelDiffMonths)) { - debugVerboseLog("Boot patch level is not within valid range."); - result = false; + Slog.e(TAG, "Boot patch level is not within valid range."); + result |= FLAG_FAILURE_PATCH_LEVEL_DIFF; } else { dumpData.mKeyBootPatchLevelInRange = true; } if (!isValidPatchLevel(attestationAttributes.getKeyVendorPatchLevel(), maxPatchLevelDiffMonths)) { - debugVerboseLog("Vendor patch level is not within valid range."); - result = false; + Slog.e(TAG, "Vendor patch level is not within valid range."); + result |= FLAG_FAILURE_PATCH_LEVEL_DIFF; } else { dumpData.mKeyVendorPatchLevelInRange = true; } if (!isValidPatchLevel(attestationAttributes.getKeyBootPatchLevel(), maxPatchLevelDiffMonths)) { - debugVerboseLog("Boot patch level is not within valid range."); - result = false; + Slog.e(TAG, "Boot patch level is not within valid range."); + result |= FLAG_FAILURE_PATCH_LEVEL_DIFF; } else { dumpData.mKeyBootPatchLevelInRange = true; } @@ -522,7 +520,7 @@ class AttestationVerificationPeerDeviceVerifier { final Set<String> ownerPackages = attestationAttributes.getApplicationPackageNameVersion().keySet(); if (!ANDROID_SYSTEM_PACKAGE_NAME_SET.equals(ownerPackages)) { - debugVerboseLog("Owner is not system, packages=" + ownerPackages); + Slog.e(TAG, "Owner is not system, packages=" + ownerPackages); return false; } @@ -548,7 +546,7 @@ class AttestationVerificationPeerDeviceVerifier { localPatchDate = LocalDate.parse(Build.VERSION.SECURITY_PATCH); } } catch (Throwable t) { - debugVerboseLog("Build.VERSION.SECURITY_PATCH: " + Slog.e(TAG, "Build.VERSION.SECURITY_PATCH: " + Build.VERSION.SECURITY_PATCH + " is not in format YYYY-MM-DD"); return false; } @@ -563,7 +561,7 @@ class AttestationVerificationPeerDeviceVerifier { // Convert remote patch dates to LocalDate. String remoteDeviceDateStr = String.valueOf(patchLevel); if (remoteDeviceDateStr.length() != 6 && remoteDeviceDateStr.length() != 8) { - debugVerboseLog("Patch level is not in format YYYYMM or YYYYMMDD"); + Slog.e(TAG, "Patch level is not in format YYYYMM or YYYYMMDD"); return false; } @@ -666,18 +664,6 @@ class AttestationVerificationPeerDeviceVerifier { } } - private static void debugVerboseLog(String str, Throwable t) { - if (DEBUG) { - Slog.v(TAG, str, t); - } - } - - private static void debugVerboseLog(String str) { - if (DEBUG) { - Slog.v(TAG, str); - } - } - /* Mutable data class for tracking dump data from verifications. */ private static class MyDumpData extends AttestationVerificationManagerService.DumpData { @@ -717,9 +703,7 @@ class AttestationVerificationPeerDeviceVerifier { @SuppressLint("WrongConstant") @Override public void dumpTo(IndentingPrintWriter writer) { - writer.println( - "Result: " + AttestationVerificationManager.verificationResultCodeToString( - mResult)); + writer.println("Result: " + mResult); if (!mCertificationFactoryAvailable) { writer.println("Certificate Factory Unavailable"); return; diff --git a/services/core/java/com/android/server/security/AttestationVerificationSelfTrustedVerifierForTesting.java b/services/core/java/com/android/server/security/AttestationVerificationSelfTrustedVerifierForTesting.java index 58df2bd982dc..5039f6f42ac3 100644 --- a/services/core/java/com/android/server/security/AttestationVerificationSelfTrustedVerifierForTesting.java +++ b/services/core/java/com/android/server/security/AttestationVerificationSelfTrustedVerifierForTesting.java @@ -16,17 +16,15 @@ package com.android.server.security; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS; +import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; import static android.security.attestationverification.AttestationVerificationManager.PARAM_CHALLENGE; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE; -import static android.security.attestationverification.AttestationVerificationManager.RESULT_SUCCESS; import static android.security.attestationverification.AttestationVerificationManager.TYPE_CHALLENGE; import android.annotation.NonNull; -import android.os.Build; import android.os.Bundle; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; -import android.util.Log; import android.util.Slog; import com.android.internal.org.bouncycastle.asn1.ASN1InputStream; @@ -64,7 +62,6 @@ import java.util.Set; */ class AttestationVerificationSelfTrustedVerifierForTesting { private static final String TAG = "AVF"; - private static final boolean DEBUG = Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.VERBOSE); // The OID for the extension Android Keymint puts into device-generated certificates. private static final String ANDROID_KEYMINT_KEY_DESCRIPTION_EXTENSION_OID = @@ -99,18 +96,6 @@ class AttestationVerificationSelfTrustedVerifierForTesting { return sAttestationVerificationSelfTrustedVerifier; } - private static void debugVerboseLog(String str, Throwable t) { - if (DEBUG) { - Slog.v(TAG, str, t); - } - } - - private static void debugVerboseLog(String str) { - if (DEBUG) { - Slog.v(TAG, str); - } - } - private AttestationVerificationSelfTrustedVerifierForTesting() throws Exception { mCertificateFactory = CertificateFactory.getInstance("X.509"); mCertPathValidator = CertPathValidator.getInstance("PKIX"); @@ -142,23 +127,42 @@ class AttestationVerificationSelfTrustedVerifierForTesting { certificates.add((X509Certificate) mCertificateFactory.generateCertificate(bis)); } } catch (CertificateException e) { - debugVerboseLog("Unable to parse certificates from attestation", e); - return RESULT_FAILURE; + Slog.e("Unable to parse certificates from attestation", e.getLocalizedMessage()); + return FLAG_FAILURE_CERTS; } - if (localBindingType == TYPE_CHALLENGE - && validateRequirements(requirements) - && checkLeafChallenge(requirements, certificates) - && verifyCertificateChain(certificates)) { - return RESULT_SUCCESS; + int result = 0; + + if (localBindingType != TYPE_CHALLENGE + || !validateRequirements(requirements)) { + Slog.e(TAG, "Local binding requirements verification failure." + localBindingType); + return FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; + } + + // Verify challenge + byte[] challenge; + try { + challenge = getChallengeFromCert(certificates.get(0)); + } catch (Throwable t) { + Slog.e("Unable to parse challenge from certificate.", t.getLocalizedMessage()); + result |= FLAG_FAILURE_CERTS; + return result; + } + if (!Arrays.equals(requirements.getByteArray(PARAM_CHALLENGE), challenge)) { + Slog.e(TAG, "Self-Trusted validation failed; challenge mismatch."); + result |= FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS; } - return RESULT_FAILURE; + if (!verifyCertificateChain(certificates)) { + result |= FLAG_FAILURE_CERTS; + } + + return result; } private boolean verifyCertificateChain(List<X509Certificate> certificates) { if (certificates.size() < 2) { - debugVerboseLog("Certificate chain less than 2 in size."); + Slog.e(TAG, "Certificate chain less than 2 in size."); return false; } @@ -170,7 +174,7 @@ class AttestationVerificationSelfTrustedVerifierForTesting { validationParams.setRevocationEnabled(false); mCertPathValidator.validate(certificatePath, validationParams); } catch (Throwable t) { - debugVerboseLog("Invalid certificate chain", t); + Slog.e(TAG, "Invalid certificate chain", t); return false; } @@ -183,34 +187,16 @@ class AttestationVerificationSelfTrustedVerifierForTesting { private boolean validateRequirements(Bundle requirements) { if (requirements.size() != 1) { - debugVerboseLog("Requirements does not contain exactly 1 key."); + Slog.e(TAG, "Requirements does not contain exactly 1 key."); return false; } if (!requirements.containsKey(PARAM_CHALLENGE)) { - debugVerboseLog("Requirements does not contain key: " + PARAM_CHALLENGE); + Slog.e(TAG, "Requirements does not contain key: " + PARAM_CHALLENGE); return false; } return true; } - private boolean checkLeafChallenge(Bundle requirements, List<X509Certificate> certificates) { - // Verify challenge - byte[] challenge; - try { - challenge = getChallengeFromCert(certificates.get(0)); - } catch (Throwable t) { - debugVerboseLog("Unable to parse challenge from certificate.", t); - return false; - } - - if (Arrays.equals(requirements.getByteArray(PARAM_CHALLENGE), challenge)) { - return true; - } else { - debugVerboseLog("Self-Trusted validation failed; challenge mismatch."); - return false; - } - } - private byte[] getChallengeFromCert(@NonNull X509Certificate x509Certificate) throws CertificateEncodingException, IOException { Certificate certificate = Certificate.getInstance( diff --git a/services/core/java/com/android/server/wm/Transition.java b/services/core/java/com/android/server/wm/Transition.java index c48590fba00d..fde502f2306c 100644 --- a/services/core/java/com/android/server/wm/Transition.java +++ b/services/core/java/com/android/server/wm/Transition.java @@ -2188,30 +2188,32 @@ class Transition implements BLASTSyncEngine.TransactionReadyListener { for (int i = onTopTasksEnd.size() - 1; i >= 0; --i) { final Task task = onTopTasksEnd.get(i); if (task.getDisplayId() != displayId) continue; - if (!enableDisplayFocusInShellTransitions() - || mOnTopDisplayStart == onTopDisplayEnd - || displayId != onTopDisplayEnd.mDisplayId) { - // If it didn't change since last report, don't report - if (reportedOnTop == null) { - if (mOnTopTasksStart.contains(task)) continue; - } else if (reportedOnTop.contains(task)) { - continue; - } - } - // Need to report it. - mParticipants.add(task); - int changeIdx = mChanges.indexOfKey(task); - if (changeIdx < 0) { - mChanges.put(task, new ChangeInfo(task)); - changeIdx = mChanges.indexOfKey(task); + if (reportedOnTop == null) { + if (mOnTopTasksStart.contains(task)) continue; + } else if (reportedOnTop.contains(task)) { + continue; } - mChanges.valueAt(changeIdx).mFlags |= ChangeInfo.FLAG_CHANGE_MOVED_TO_TOP; + addToTopChange(task); } // Swap in the latest on-top tasks. mController.mLatestOnTopTasksReported.put(displayId, onTopTasksEnd); onTopTasksEnd = reportedOnTop != null ? reportedOnTop : new ArrayList<>(); onTopTasksEnd.clear(); + + if (enableDisplayFocusInShellTransitions() + && mOnTopDisplayStart != onTopDisplayEnd + && displayId == onTopDisplayEnd.mDisplayId) { + addToTopChange(onTopDisplayEnd); + } + } + } + + private void addToTopChange(@NonNull WindowContainer wc) { + mParticipants.add(wc); + if (!mChanges.containsKey(wc)) { + mChanges.put(wc, new ChangeInfo(wc)); } + mChanges.get(wc).mFlags |= ChangeInfo.FLAG_CHANGE_MOVED_TO_TOP; } private void postCleanupOnFailure() { diff --git a/services/core/java/com/android/server/wm/WindowManagerShellCommand.java b/services/core/java/com/android/server/wm/WindowManagerShellCommand.java index 6d7396f1f477..21ed8d793b24 100644 --- a/services/core/java/com/android/server/wm/WindowManagerShellCommand.java +++ b/services/core/java/com/android/server/wm/WindowManagerShellCommand.java @@ -16,6 +16,9 @@ package com.android.server.wm; +import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM; +import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN; +import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED; import static android.os.Build.IS_USER; import static android.view.CrossWindowBlurListeners.CROSS_WINDOW_BLUR_SUPPORTED; @@ -30,6 +33,7 @@ import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_VERTICAL_RE import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_VERTICAL_REACHABILITY_POSITION_CENTER; import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_VERTICAL_REACHABILITY_POSITION_TOP; +import android.app.WindowConfiguration; import android.content.res.Resources.NotFoundException; import android.graphics.Color; import android.graphics.Point; @@ -157,6 +161,10 @@ public class WindowManagerShellCommand extends ShellCommand { return runReset(pw); case "disable-blur": return runSetBlurDisabled(pw); + case "set-display-windowing-mode": + return runSetDisplayWindowingMode(pw); + case "get-display-windowing-mode": + return runGetDisplayWindowingMode(pw); case "shell": return runWmShellCommand(pw); default: @@ -1434,6 +1442,35 @@ public class WindowManagerShellCommand extends ShellCommand { return 0; } + private int runSetDisplayWindowingMode(PrintWriter pw) throws RemoteException { + int displayId = Display.DEFAULT_DISPLAY; + String arg = getNextArgRequired(); + if ("-d".equals(arg)) { + displayId = Integer.parseInt(getNextArgRequired()); + arg = getNextArgRequired(); + } + + final int windowingMode = Integer.parseInt(arg); + mInterface.setWindowingMode(displayId, windowingMode); + + return 0; + } + + private int runGetDisplayWindowingMode(PrintWriter pw) throws RemoteException { + int displayId = Display.DEFAULT_DISPLAY; + final String arg = getNextArg(); + if ("-d".equals(arg)) { + displayId = Integer.parseInt(getNextArgRequired()); + } + + final int windowingMode = mInterface.getWindowingMode(displayId); + pw.println("display windowing mode=" + + WindowConfiguration.windowingModeToString(windowingMode) + " for displayId=" + + displayId); + + return 0; + } + private int runWmShellCommand(PrintWriter pw) { String arg = getNextArg(); @@ -1512,6 +1549,9 @@ public class WindowManagerShellCommand extends ShellCommand { // set-multi-window-config runResetMultiWindowConfig(); + // set-display-windowing-mode + mInternal.setWindowingMode(displayId, WINDOWING_MODE_UNDEFINED); + pw.println("Reset all settings for displayId=" + displayId); return 0; } @@ -1552,6 +1592,12 @@ public class WindowManagerShellCommand extends ShellCommand { printLetterboxHelp(pw); printMultiWindowConfigHelp(pw); + pw.println(" set-display-windowing-mode [-d DISPLAY_ID] [mode_id]"); + pw.println(" As mode_id, use " + WINDOWING_MODE_UNDEFINED + " for undefined, " + + WINDOWING_MODE_FREEFORM + " for freeform, " + WINDOWING_MODE_FULLSCREEN + " for" + + " fullscreen"); + pw.println(" get-display-windowing-mode [-d DISPLAY_ID]"); + pw.println(" reset [-d DISPLAY_ID]"); pw.println(" Reset all override settings."); if (!IS_USER) { diff --git a/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java b/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java index 2bb86bc305a7..1a42e80265cb 100644 --- a/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java +++ b/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java @@ -687,6 +687,7 @@ public class SoundTriggerService extends SystemService { @Override public int startRecognitionForService(ParcelUuid soundModelId, Bundle params, ComponentName detectionService, SoundTrigger.RecognitionConfig config) { + final UserHandle userHandle = Binder.getCallingUserHandle(); mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION_SERVICE, getUuid(soundModelId))); try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { @@ -699,7 +700,7 @@ public class SoundTriggerService extends SystemService { IRecognitionStatusCallback callback = new RemoteSoundTriggerDetectionService(soundModelId.getUuid(), params, - detectionService, Binder.getCallingUserHandle(), config); + detectionService, userHandle, config); synchronized (mLock) { SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); diff --git a/telephony/java/android/telephony/TelephonyManager.java b/telephony/java/android/telephony/TelephonyManager.java index 92effe05882a..ff302f6b1a65 100644 --- a/telephony/java/android/telephony/TelephonyManager.java +++ b/telephony/java/android/telephony/TelephonyManager.java @@ -10613,20 +10613,31 @@ public class TelephonyManager { return null; } - /** @hide */ + /** + * Get the names of packages with carrier privileges for the current subscription. + * + * @throws UnsupportedOperationException If the device does not have {@link + * PackageManager#FEATURE_TELEPHONY_SUBSCRIPTION} + * @hide + */ + @FlaggedApi(android.os.Flags.FLAG_MAINLINE_VCN_PLATFORM_API) + @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES) @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE) - public List<String> getPackagesWithCarrierPrivileges() { + @RequiresFeature(PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION) + @NonNull + public Set<String> getPackagesWithCarrierPrivileges() { + final Set<String> result = new HashSet<>(); try { ITelephony telephony = getITelephony(); if (telephony != null) { - return telephony.getPackagesWithCarrierPrivileges(getPhoneId()); + result.addAll(telephony.getPackagesWithCarrierPrivileges(getPhoneId())); } } catch (RemoteException ex) { Rlog.e(TAG, "getPackagesWithCarrierPrivileges RemoteException", ex); } catch (NullPointerException ex) { Rlog.e(TAG, "getPackagesWithCarrierPrivileges NPE", ex); } - return Collections.EMPTY_LIST; + return result; } /** diff --git a/tests/AttestationVerificationTest/src/android/security/attestationverification/PeerDeviceSystemAttestationVerificationTest.kt b/tests/AttestationVerificationTest/src/android/security/attestationverification/PeerDeviceSystemAttestationVerificationTest.kt index ad95fbc36867..88ebf3edc7ed 100644 --- a/tests/AttestationVerificationTest/src/android/security/attestationverification/PeerDeviceSystemAttestationVerificationTest.kt +++ b/tests/AttestationVerificationTest/src/android/security/attestationverification/PeerDeviceSystemAttestationVerificationTest.kt @@ -2,10 +2,11 @@ package android.security.attestationverification import android.app.Activity import android.os.Bundle +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS import android.security.attestationverification.AttestationVerificationManager.PARAM_CHALLENGE import android.security.attestationverification.AttestationVerificationManager.PARAM_PUBLIC_KEY import android.security.attestationverification.AttestationVerificationManager.PROFILE_PEER_DEVICE -import android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE import android.security.attestationverification.AttestationVerificationManager.TYPE_CHALLENGE import android.security.attestationverification.AttestationVerificationManager.TYPE_PUBLIC_KEY import android.security.attestationverification.AttestationVerificationManager.TYPE_UNKNOWN @@ -54,7 +55,7 @@ class PeerDeviceSystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -66,7 +67,7 @@ class PeerDeviceSystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -80,7 +81,7 @@ class PeerDeviceSystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) val future2 = CompletableFuture<Int>() val challengeRequirements = Bundle() @@ -90,7 +91,7 @@ class PeerDeviceSystemAttestationVerificationTest { future2.complete(result) } - assertThat(future2.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future2.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -104,7 +105,7 @@ class PeerDeviceSystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -118,7 +119,7 @@ class PeerDeviceSystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_CERTS) } @Test @@ -131,7 +132,7 @@ class PeerDeviceSystemAttestationVerificationTest { invalidAttestationByteArray, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_CERTS) } private fun <T> CompletableFuture<T>.getSoon(): T { diff --git a/tests/AttestationVerificationTest/src/android/security/attestationverification/SystemAttestationVerificationTest.kt b/tests/AttestationVerificationTest/src/android/security/attestationverification/SystemAttestationVerificationTest.kt index 8f06b4a2ea0a..e77364de8747 100644 --- a/tests/AttestationVerificationTest/src/android/security/attestationverification/SystemAttestationVerificationTest.kt +++ b/tests/AttestationVerificationTest/src/android/security/attestationverification/SystemAttestationVerificationTest.kt @@ -2,6 +2,9 @@ package android.security.attestationverification import android.os.Bundle import android.app.Activity +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_UNSUPPORTED_PROFILE import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -14,9 +17,6 @@ import com.google.common.truth.Truth.assertThat import android.security.attestationverification.AttestationVerificationManager.PARAM_CHALLENGE import android.security.attestationverification.AttestationVerificationManager.PROFILE_SELF_TRUSTED import android.security.attestationverification.AttestationVerificationManager.PROFILE_UNKNOWN -import android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE -import android.security.attestationverification.AttestationVerificationManager.RESULT_SUCCESS -import android.security.attestationverification.AttestationVerificationManager.RESULT_UNKNOWN import android.security.attestationverification.AttestationVerificationManager.TYPE_PUBLIC_KEY import android.security.attestationverification.AttestationVerificationManager.TYPE_CHALLENGE import android.security.keystore.KeyGenParameterSpec @@ -58,19 +58,19 @@ class SystemAttestationVerificationTest { future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_UNKNOWN) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_UNSUPPORTED_PROFILE) } @Test fun verifyAttestation_returnsFailureWithEmptyAttestation() { val future = CompletableFuture<Int>() - val profile = AttestationProfile(PROFILE_SELF_TRUSTED) - avm.verifyAttestation(profile, TYPE_CHALLENGE, Bundle(), ByteArray(0), - activity.mainExecutor) { result, _ -> + val selfTrusted = TestSelfTrustedAttestation("test", "challengeStr") + avm.verifyAttestation(selfTrusted.profile, selfTrusted.localBindingType, + selfTrusted.requirements, ByteArray(0), activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_CERTS) } @Test @@ -81,7 +81,7 @@ class SystemAttestationVerificationTest { Bundle(), selfTrusted.attestation, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -92,7 +92,7 @@ class SystemAttestationVerificationTest { selfTrusted.requirements, selfTrusted.attestation, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -106,7 +106,7 @@ class SystemAttestationVerificationTest { wrongKeyRequirements, selfTrusted.attestation, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -119,7 +119,7 @@ class SystemAttestationVerificationTest { wrongChallengeRequirements, selfTrusted.attestation, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_FAILURE) + assertThat(future.getSoon()).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } // TODO(b/216144791): Add more failure tests for PROFILE_SELF_TRUSTED. @@ -131,20 +131,7 @@ class SystemAttestationVerificationTest { selfTrusted.requirements, selfTrusted.attestation, activity.mainExecutor) { result, _ -> future.complete(result) } - assertThat(future.getSoon()).isEqualTo(RESULT_SUCCESS) - } - - @Test - fun verifyToken_returnsUnknown() { - val future = CompletableFuture<Int>() - val profile = AttestationProfile(PROFILE_SELF_TRUSTED) - avm.verifyAttestation(profile, TYPE_PUBLIC_KEY, Bundle(), ByteArray(0), - activity.mainExecutor) { _, token -> - val result = avm.verifyToken(profile, TYPE_PUBLIC_KEY, Bundle(), token, null) - future.complete(result) - } - - assertThat(future.getSoon()).isEqualTo(RESULT_UNKNOWN) + assertThat(future.getSoon()).isEqualTo(0) } @Test diff --git a/tests/AttestationVerificationTest/src/com/android/server/security/AttestationVerificationPeerDeviceVerifierTest.kt b/tests/AttestationVerificationTest/src/com/android/server/security/AttestationVerificationPeerDeviceVerifierTest.kt index 4712d6b51bd5..4d1a1a55af74 100644 --- a/tests/AttestationVerificationTest/src/com/android/server/security/AttestationVerificationPeerDeviceVerifierTest.kt +++ b/tests/AttestationVerificationTest/src/com/android/server/security/AttestationVerificationPeerDeviceVerifierTest.kt @@ -3,11 +3,12 @@ package com.android.server.security import android.app.Activity import android.content.Context import android.os.Bundle +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS +import android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_PATCH_LEVEL_DIFF import android.security.attestationverification.AttestationVerificationManager.PARAM_CHALLENGE import android.security.attestationverification.AttestationVerificationManager.PARAM_MAX_PATCH_LEVEL_DIFF_MONTHS import android.security.attestationverification.AttestationVerificationManager.PARAM_PUBLIC_KEY -import android.security.attestationverification.AttestationVerificationManager.RESULT_FAILURE -import android.security.attestationverification.AttestationVerificationManager.RESULT_SUCCESS import android.security.attestationverification.AttestationVerificationManager.TYPE_CHALLENGE import android.security.attestationverification.AttestationVerificationManager.TYPE_PUBLIC_KEY import android.util.IndentingPrintWriter @@ -72,7 +73,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_SUCCESS) + assertThat(result).isEqualTo(0) } @Test @@ -88,7 +89,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_SUCCESS) + assertThat(result).isEqualTo(0) } @Test @@ -108,7 +109,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_PUBLIC_KEY, pkRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_SUCCESS) + assertThat(result).isEqualTo(0) } @Test @@ -126,7 +127,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TEST_OWNED_BY_SYSTEM_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_SUCCESS) + assertThat(result).isEqualTo(0) } @Test @@ -143,7 +144,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } @Test @@ -159,7 +160,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_PATCH_LEVEL_DIFF) } @Test @@ -176,7 +177,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_SUCCESS) + assertThat(result).isEqualTo(0) } @Test @@ -191,10 +192,28 @@ class AttestationVerificationPeerDeviceVerifierTest { val result = verifier.verifyAttestation( TYPE_CHALLENGE, challengeRequirements, - // The patch date of this file is early 2022 TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_PATCH_LEVEL_DIFF) + } + + @Test + fun verifyAttestation_returnsFailureOwnedBySystemAndPatchDataNotWithinMaxPatchDiff() { + val verifier = AttestationVerificationPeerDeviceVerifier( + context, dumpLogger, trustAnchors, false, LocalDate.of(2024, 10, 1), + LocalDate.of(2024, 9, 1) + ) + val challengeRequirements = Bundle() + challengeRequirements.putByteArray(PARAM_CHALLENGE, "player456".encodeToByteArray()) + challengeRequirements.putBoolean("android.key_owned_by_system", true) + challengeRequirements.putInt(PARAM_MAX_PATCH_LEVEL_DIFF_MONTHS, 24) + + val result = verifier.verifyAttestation( + TYPE_CHALLENGE, challengeRequirements, + TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() + ) + // Both "owned by system" and "patch level diff" checks should fail. + assertThat(result).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS or FLAG_FAILURE_PATCH_LEVEL_DIFF) } @Test @@ -210,7 +229,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_CERTS) } @Test @@ -232,7 +251,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_CERTS) } fun verifyAttestation_returnsFailureChallenge() { @@ -247,7 +266,7 @@ class AttestationVerificationPeerDeviceVerifierTest { TYPE_CHALLENGE, challengeRequirements, TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME.fromPEMFileToByteArray() ) - assertThat(result).isEqualTo(RESULT_FAILURE) + assertThat(result).isEqualTo(FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS) } private fun String.fromPEMFileToCerts(): Collection<Certificate> { @@ -281,6 +300,7 @@ class AttestationVerificationPeerDeviceVerifierTest { companion object { private const val TAG = "AVFTest" private const val TEST_ROOT_CERT_FILENAME = "test_root_certs.pem" + // Local patch date is 20220105 private const val TEST_ATTESTATION_WITH_ROOT_CERT_FILENAME = "test_attestation_with_root_certs.pem" private const val TEST_ATTESTATION_CERT_FILENAME = "test_attestation_wrong_root_certs.pem" diff --git a/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt b/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt index c61a25021949..9f4df90422eb 100644 --- a/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt +++ b/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt @@ -39,6 +39,7 @@ import com.android.cts.input.inputeventmatchers.withSource import junit.framework.Assert.fail import org.hamcrest.Matchers.allOf import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.rules.TestName @@ -107,6 +108,7 @@ class UinputRecordingIntegrationTests { parser = InputJsonParser(instrumentation.context) } + @Ignore("b/366602644") @Test fun testEvemuRecording() { VirtualDisplayActivityScenario.AutoClose<CaptureEventActivity>( diff --git a/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java b/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java index b5cc5536532c..f1f74bca2ef9 100644 --- a/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java +++ b/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java @@ -206,7 +206,7 @@ public class TelephonySubscriptionTrackerTest { .getAllSubscriptionInfoList(); doReturn(mTelephonyManager).when(mTelephonyManager).createForSubscriptionId(anyInt()); - setPrivilegedPackagesForMock(Collections.singletonList(PACKAGE_NAME)); + setPrivilegedPackagesForMock(Collections.singleton(PACKAGE_NAME)); } private IntentFilter getIntentFilter() { @@ -293,7 +293,7 @@ public class TelephonySubscriptionTrackerTest { Collections.singletonMap(TEST_SUBSCRIPTION_ID_1, TEST_CARRIER_CONFIG_WRAPPER)); } - private void setPrivilegedPackagesForMock(@NonNull List<String> privilegedPackages) { + private void setPrivilegedPackagesForMock(@NonNull Set<String> privilegedPackages) { doReturn(privilegedPackages).when(mTelephonyManager).getPackagesWithCarrierPrivileges(); } @@ -390,7 +390,7 @@ public class TelephonySubscriptionTrackerTest { @Test public void testOnSubscriptionsChangedFired_onActiveSubIdsChanged() throws Exception { setupReadySubIds(); - setPrivilegedPackagesForMock(Collections.emptyList()); + setPrivilegedPackagesForMock(Collections.emptySet()); doReturn(TEST_SUBSCRIPTION_ID_2).when(mDeps).getActiveDataSubscriptionId(); final ActiveDataSubscriptionIdListener listener = getActiveDataSubscriptionIdListener(); @@ -411,7 +411,7 @@ public class TelephonySubscriptionTrackerTest { public void testOnSubscriptionsChangedFired_WithReadySubidsNoPrivilegedPackages() throws Exception { setupReadySubIds(); - setPrivilegedPackagesForMock(Collections.emptyList()); + setPrivilegedPackagesForMock(Collections.emptySet()); final OnSubscriptionsChangedListener listener = getOnSubscriptionsChangedListener(); listener.onSubscriptionsChanged(); @@ -567,7 +567,7 @@ public class TelephonySubscriptionTrackerTest { verify(mCallback).onNewSnapshot(eq(buildExpectedSnapshot(TEST_PRIVILEGED_PACKAGES))); // Simulate a loss of carrier privileges - setPrivilegedPackagesForMock(Collections.emptyList()); + setPrivilegedPackagesForMock(Collections.emptySet()); listener.onSubscriptionsChanged(); mTestLooper.dispatchAll(); diff --git a/tools/aapt2/ResourceParser.cpp b/tools/aapt2/ResourceParser.cpp index da092f43caa4..fb576df248be 100644 --- a/tools/aapt2/ResourceParser.cpp +++ b/tools/aapt2/ResourceParser.cpp @@ -110,7 +110,7 @@ struct ParsedResource { std::optional<OverlayableItem> overlayable_item; std::optional<StagedId> staged_alias; std::optional<FeatureFlagAttribute> flag; - FlagStatus flag_status; + FlagStatus flag_status = FlagStatus::NoFlag; std::string comment; std::unique_ptr<Value> value; diff --git a/tools/processors/property_cache/OWNERS b/tools/processors/property_cache/OWNERS new file mode 100644 index 000000000000..78650168807e --- /dev/null +++ b/tools/processors/property_cache/OWNERS @@ -0,0 +1,3 @@ +include /ACTIVITY_MANAGER_OWNERS +include /BROADCASTS_OWNERS +include /MULTIUSER_OWNERS
\ No newline at end of file |