diff options
66 files changed, 3856 insertions, 515 deletions
diff --git a/core/api/system-current.txt b/core/api/system-current.txt index 1a1f3548fa74..4b6c62ecb032 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -91,6 +91,7 @@ package android { field public static final String BIND_TRANSLATION_SERVICE = "android.permission.BIND_TRANSLATION_SERVICE"; field public static final String BIND_TRUST_AGENT = "android.permission.BIND_TRUST_AGENT"; field public static final String BIND_TV_REMOTE_SERVICE = "android.permission.BIND_TV_REMOTE_SERVICE"; + field @FlaggedApi("android.content.pm.verification_service") public static final String BIND_VERIFICATION_AGENT = "android.permission.BIND_VERIFICATION_AGENT"; field public static final String BIND_VISUAL_QUERY_DETECTION_SERVICE = "android.permission.BIND_VISUAL_QUERY_DETECTION_SERVICE"; field public static final String BIND_WALLPAPER_EFFECTS_GENERATION_SERVICE = "android.permission.BIND_WALLPAPER_EFFECTS_GENERATION_SERVICE"; field public static final String BIND_WEARABLE_SENSING_SERVICE = "android.permission.BIND_WEARABLE_SENSING_SERVICE"; @@ -412,6 +413,7 @@ package android { field @FlaggedApi("android.app.ondeviceintelligence.flags.enable_on_device_intelligence") public static final String USE_ON_DEVICE_INTELLIGENCE = "android.permission.USE_ON_DEVICE_INTELLIGENCE"; field public static final String USE_RESERVED_DISK = "android.permission.USE_RESERVED_DISK"; field public static final String UWB_PRIVILEGED = "android.permission.UWB_PRIVILEGED"; + field @FlaggedApi("android.content.pm.verification_service") public static final String VERIFICATION_AGENT = "android.permission.VERIFICATION_AGENT"; field @FlaggedApi("android.os.vibrator.vendor_vibration_effects") public static final String VIBRATE_VENDOR_EFFECTS = "android.permission.VIBRATE_VENDOR_EFFECTS"; field public static final String WHITELIST_AUTO_REVOKE_PERMISSIONS = "android.permission.WHITELIST_AUTO_REVOKE_PERMISSIONS"; field public static final String WHITELIST_RESTRICTED_PERMISSIONS = "android.permission.WHITELIST_RESTRICTED_PERMISSIONS"; @@ -4303,6 +4305,7 @@ package android.content.pm { method @Deprecated @RequiresPermission(android.Manifest.permission.INTENT_FILTER_VERIFICATION_AGENT) public abstract void verifyIntentFilter(int, int, @NonNull java.util.List<java.lang.String>); field public static final String ACTION_REQUEST_PERMISSIONS = "android.content.pm.action.REQUEST_PERMISSIONS"; field public static final String ACTION_REQUEST_PERMISSIONS_FOR_OTHER = "android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER"; + field @FlaggedApi("android.content.pm.verification_service") public static final String ACTION_VERIFY_PACKAGE = "android.content.pm.action.VERIFY_PACKAGE"; field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_APK = 1; // 0x1 field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_INSTALLER = 2; // 0x2 field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_SYSTEM_IMAGE = 3; // 0x3 @@ -4616,6 +4619,61 @@ package android.content.pm.verify.domain { } +package android.content.pm.verify.pkg { + + @FlaggedApi("android.content.pm.verification_service") public final class VerificationSession implements android.os.Parcelable { + method public int describeContents(); + method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public long extendTimeRemaining(long); + method @NonNull public java.util.List<android.content.pm.SharedLibraryInfo> getDeclaredLibraries(); + method @NonNull public android.os.PersistableBundle getExtensionParams(); + method public int getId(); + method public int getInstallSessionId(); + method @NonNull public String getPackageName(); + method @NonNull public android.content.pm.SigningInfo getSigningInfo(); + method @NonNull public android.net.Uri getStagedPackageUri(); + method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public long getTimeoutTime(); + method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationComplete(@NonNull android.content.pm.verify.pkg.VerificationStatus); + method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationComplete(@NonNull android.content.pm.verify.pkg.VerificationStatus, @NonNull android.os.PersistableBundle); + method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationIncomplete(int); + method public void writeToParcel(@NonNull android.os.Parcel, int); + field @NonNull public static final android.os.Parcelable.Creator<android.content.pm.verify.pkg.VerificationSession> CREATOR; + field public static final int VERIFICATION_INCOMPLETE_NETWORK_LIMITED = 2; // 0x2 + field public static final int VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE = 1; // 0x1 + field public static final int VERIFICATION_INCOMPLETE_UNKNOWN = 0; // 0x0 + } + + @FlaggedApi("android.content.pm.verification_service") public final class VerificationStatus implements android.os.Parcelable { + method public int describeContents(); + method public int getAslStatus(); + method @NonNull public String getFailureMessage(); + method public boolean isVerified(); + method public void writeToParcel(@NonNull android.os.Parcel, int); + field @NonNull public static final android.os.Parcelable.Creator<android.content.pm.verify.pkg.VerificationStatus> CREATOR; + field public static final int VERIFIER_STATUS_ASL_BAD = 2; // 0x2 + field public static final int VERIFIER_STATUS_ASL_GOOD = 1; // 0x1 + field public static final int VERIFIER_STATUS_ASL_UNDEFINED = 0; // 0x0 + } + + public static final class VerificationStatus.Builder { + ctor public VerificationStatus.Builder(); + method @NonNull public android.content.pm.verify.pkg.VerificationStatus build(); + method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setAslStatus(int); + method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setFailureMessage(@NonNull String); + method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setVerified(boolean); + } + + @FlaggedApi("android.content.pm.verification_service") public abstract class VerifierService extends android.app.Service { + ctor public VerifierService(); + method @Nullable public android.os.IBinder onBind(@Nullable android.content.Intent); + method public abstract void onPackageNameAvailable(@NonNull String); + method public abstract void onVerificationCancelled(@NonNull String); + method public abstract void onVerificationRequired(@NonNull android.content.pm.verify.pkg.VerificationSession); + method public abstract void onVerificationRetry(@NonNull android.content.pm.verify.pkg.VerificationSession); + method public abstract void onVerificationTimeout(int); + } + +} + package android.content.rollback { public final class PackageRollbackInfo implements android.os.Parcelable { diff --git a/core/java/android/content/pm/PackageManager.java b/core/java/android/content/pm/PackageManager.java index fb2655c771c4..e985f88f38fc 100644 --- a/core/java/android/content/pm/PackageManager.java +++ b/core/java/android/content/pm/PackageManager.java @@ -5039,6 +5039,25 @@ public abstract class PackageManager { "android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER"; /** + * Used by the system to query a {@link android.content.pm.verify.pkg.VerifierService} provider, + * which registers itself via an intent-filter handling this action. + * + * <p class="note">Only the system can bind to such a verifier service. This is protected by the + * {@link android.Manifest.permission#BIND_VERIFICATION_AGENT} permission. The verifier service + * app should protect the service by adding this permission in the service declaration in its + * manifest. + * <p> + * A verifier service must be a privileged app and hold the + * {@link android.Manifest.permission#VERIFICATION_AGENT} permission. + * + * @hide + */ + @SystemApi + @FlaggedApi(android.content.pm.Flags.FLAG_VERIFICATION_SERVICE) + @SdkConstant(SdkConstantType.SERVICE_ACTION) + public static final String ACTION_VERIFY_PACKAGE = "android.content.pm.action.VERIFY_PACKAGE"; + + /** * The names of the requested permissions. * <p> * <strong>Type:</strong> String[] diff --git a/core/java/android/content/pm/flags.aconfig b/core/java/android/content/pm/flags.aconfig index 160cbdffe5bb..300740e84c60 100644 --- a/core/java/android/content/pm/flags.aconfig +++ b/core/java/android/content/pm/flags.aconfig @@ -309,3 +309,11 @@ flag { description: "Feature flag to enable the holder of SYSTEM_APP_PROTECTION_SERVICE role to silently delete packages. To be deprecated by delete_packages_silently." bug: "361776825" } + +flag { + name: "verification_service" + namespace: "package_manager_service" + description: "Feature flag to enable the new verification service." + bug: "360129103" + is_fixed_read_only: true +} diff --git a/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl b/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl new file mode 100644 index 000000000000..38a7956603ae --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl @@ -0,0 +1,34 @@ +/* + * 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 android.content.pm.verify.pkg; + +import android.content.pm.verify.pkg.VerificationStatus; +import android.os.PersistableBundle; + +/** + * Oneway interface that allows the verifier to send response or verification results back to + * the system. + * @hide + */ +oneway interface IVerificationSessionCallback { + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)") + void reportVerificationIncomplete(int verificationId, int reason); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)") + void reportVerificationComplete(int verificationId, in VerificationStatus status); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)") + void reportVerificationCompleteWithExtensionResponse(int verificationId, in VerificationStatus status, in PersistableBundle response); +} diff --git a/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl b/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl new file mode 100644 index 000000000000..7a9484abd1b1 --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl @@ -0,0 +1,28 @@ +/* + * 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 android.content.pm.verify.pkg; + +/** + * Non-oneway interface that allows the verifier to retrieve information from the system. + * @hide + */ +interface IVerificationSessionInterface { + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)") + long getTimeoutTime(int verificationId); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)") + long extendTimeRemaining(int verificationId, long additionalMs); +}
\ No newline at end of file diff --git a/core/java/android/content/pm/verify/pkg/IVerifierService.aidl b/core/java/android/content/pm/verify/pkg/IVerifierService.aidl new file mode 100644 index 000000000000..d3071fd67b9d --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/IVerifierService.aidl @@ -0,0 +1,31 @@ +/* + * 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 android.content.pm.verify.pkg; + +import android.content.pm.verify.pkg.VerificationSession; + +/** + * Oneway interface that allows the system to communicate to the verifier service agent. + * @hide + */ +oneway interface IVerifierService { + void onPackageNameAvailable(in String packageName); + void onVerificationCancelled(in String packageName); + void onVerificationRequired(in VerificationSession session); + void onVerificationRetry(in VerificationSession session); + void onVerificationTimeout(int verificationId); +}
\ No newline at end of file diff --git a/core/java/android/content/pm/verify/pkg/VerificationSession.aidl b/core/java/android/content/pm/verify/pkg/VerificationSession.aidl new file mode 100644 index 000000000000..ac855850a86c --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/VerificationSession.aidl @@ -0,0 +1,20 @@ +/* + * 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 android.content.pm.verify.pkg; + +/** @hide */ +parcelable VerificationSession; diff --git a/core/java/android/content/pm/verify/pkg/VerificationSession.java b/core/java/android/content/pm/verify/pkg/VerificationSession.java new file mode 100644 index 000000000000..70b4a022f521 --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/VerificationSession.java @@ -0,0 +1,276 @@ +/* + * 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 android.content.pm.verify.pkg; + +import android.annotation.FlaggedApi; +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.RequiresPermission; +import android.annotation.SystemApi; +import android.content.pm.Flags; +import android.content.pm.SharedLibraryInfo; +import android.content.pm.SigningInfo; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.PersistableBundle; +import android.os.RemoteException; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** + * This class is used by the system to describe the details about a verification request sent to the + * verification agent, aka the verifier. It includes the interfaces for the verifier to communicate + * back to the system. + * @hide + */ +@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE) +@SystemApi +public final class VerificationSession implements Parcelable { + /** + * The verification cannot be completed because of unknown reasons. + */ + public static final int VERIFICATION_INCOMPLETE_UNKNOWN = 0; + /** + * The verification cannot be completed because the network is unavailable. + */ + public static final int VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE = 1; + /** + * The verification cannot be completed because the network is limited. + */ + public static final int VERIFICATION_INCOMPLETE_NETWORK_LIMITED = 2; + + /** + * @hide + */ + @IntDef(prefix = {"VERIFICATION_INCOMPLETE_"}, value = { + VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE, + VERIFICATION_INCOMPLETE_NETWORK_LIMITED, + VERIFICATION_INCOMPLETE_UNKNOWN, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface VerificationIncompleteReason { + } + + private final int mId; + private final int mInstallSessionId; + @NonNull + private final String mPackageName; + @NonNull + private final Uri mStagedPackageUri; + @NonNull + private final SigningInfo mSigningInfo; + @NonNull + private final List<SharedLibraryInfo> mDeclaredLibraries; + @NonNull + private final PersistableBundle mExtensionParams; + @NonNull + private final IVerificationSessionInterface mSession; + @NonNull + private final IVerificationSessionCallback mCallback; + + /** + * Constructor used by the system to describe the details of a verification session. + * @hide + */ + public VerificationSession(int id, int installSessionId, @NonNull String packageName, + @NonNull Uri stagedPackageUri, @NonNull SigningInfo signingInfo, + @NonNull List<SharedLibraryInfo> declaredLibraries, + @NonNull PersistableBundle extensionParams, + @NonNull IVerificationSessionInterface session, + @NonNull IVerificationSessionCallback callback) { + mId = id; + mInstallSessionId = installSessionId; + mPackageName = packageName; + mStagedPackageUri = stagedPackageUri; + mSigningInfo = signingInfo; + mDeclaredLibraries = declaredLibraries; + mExtensionParams = extensionParams; + mSession = session; + mCallback = callback; + } + + /** + * A unique identifier tied to this specific verification session. + */ + public int getId() { + return mId; + } + + /** + * The package name of the app that is to be verified. + */ + public @NonNull String getPackageName() { + return mPackageName; + } + + /** + * The id of the installation session associated with the verification. + */ + public int getInstallSessionId() { + return mInstallSessionId; + } + + /** + * The Uri of the path where the package's code files are located. + */ + public @NonNull Uri getStagedPackageUri() { + return mStagedPackageUri; + } + + /** + * Signing info of the package to be verified. + */ + public @NonNull SigningInfo getSigningInfo() { + return mSigningInfo; + } + + /** + * Returns a mapping of any shared libraries declared in the manifest + * to the {@link SharedLibraryInfo#Type} that is declared. This will be an empty + * map if no shared libraries are declared by the package. + */ + @NonNull + public List<SharedLibraryInfo> getDeclaredLibraries() { + return Collections.unmodifiableList(mDeclaredLibraries); + } + + /** + * Returns any extension params associated with the verification request. + */ + @NonNull + public PersistableBundle getExtensionParams() { + return mExtensionParams; + } + + /** + * Get the value of Clock.elapsedRealtime() at which time this verification + * will timeout as incomplete if no other verification response is provided. + */ + @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) + public long getTimeoutTime() { + try { + return mSession.getTimeoutTime(mId); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Extend the timeout for this session by the provided additionalMs to + * fetch relevant information over the network or wait for the network. + * This may be called multiple times. If the request would bypass any max + * duration by the system, the method will return a lower value than the + * requested amount that indicates how much the time was extended. + */ + @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) + public long extendTimeRemaining(long additionalMs) { + try { + return mSession.extendTimeRemaining(mId, additionalMs); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Report to the system that verification could not be completed along + * with an approximate reason to pass on to the installer. + */ + @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) + public void reportVerificationIncomplete(@VerificationIncompleteReason int reason) { + try { + mCallback.reportVerificationIncomplete(mId, reason); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Report to the system that the verification has completed and the + * install process may act on that status to either block in the case + * of failure or continue to process the install in the case of success. + */ + @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) + public void reportVerificationComplete(@NonNull VerificationStatus status) { + try { + mCallback.reportVerificationComplete(mId, status); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Same as {@link #reportVerificationComplete(VerificationStatus)}, but also provide + * a result to the extension params provided in the request, which will be passed to the + * installer in the installation result. + */ + @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) + public void reportVerificationComplete(@NonNull VerificationStatus status, + @NonNull PersistableBundle response) { + try { + mCallback.reportVerificationCompleteWithExtensionResponse(mId, status, response); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + private VerificationSession(@NonNull Parcel in) { + mId = in.readInt(); + mInstallSessionId = in.readInt(); + mPackageName = in.readString8(); + mStagedPackageUri = Uri.CREATOR.createFromParcel(in); + mSigningInfo = SigningInfo.CREATOR.createFromParcel(in); + mDeclaredLibraries = in.createTypedArrayList(SharedLibraryInfo.CREATOR); + mExtensionParams = in.readPersistableBundle(getClass().getClassLoader()); + mSession = IVerificationSessionInterface.Stub.asInterface(in.readStrongBinder()); + mCallback = IVerificationSessionCallback.Stub.asInterface(in.readStrongBinder()); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mId); + dest.writeInt(mInstallSessionId); + dest.writeString8(mPackageName); + Uri.writeToParcel(dest, mStagedPackageUri); + mSigningInfo.writeToParcel(dest, flags); + dest.writeTypedList(mDeclaredLibraries); + dest.writePersistableBundle(mExtensionParams); + dest.writeStrongBinder(mSession.asBinder()); + dest.writeStrongBinder(mCallback.asBinder()); + } + + @NonNull + public static final Creator<VerificationSession> CREATOR = new Creator<>() { + @Override + public VerificationSession createFromParcel(@NonNull Parcel in) { + return new VerificationSession(in); + } + + @Override + public VerificationSession[] newArray(int size) { + return new VerificationSession[size]; + } + }; +} diff --git a/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl b/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl new file mode 100644 index 000000000000..6a1cb4f2d1d2 --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl @@ -0,0 +1,20 @@ +/* + * 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 android.content.pm.verify.pkg; + +/** @hide */ +parcelable VerificationStatus; diff --git a/core/java/android/content/pm/verify/pkg/VerificationStatus.java b/core/java/android/content/pm/verify/pkg/VerificationStatus.java new file mode 100644 index 000000000000..4d0379d79773 --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/VerificationStatus.java @@ -0,0 +1,166 @@ +/* + * 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 android.content.pm.verify.pkg; + +import android.annotation.FlaggedApi; +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.SystemApi; +import android.content.pm.Flags; +import android.os.Parcel; +import android.os.Parcelable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * This class is used by the verifier to describe the status of the verification request, whether + * it's successful or it has failed along with any relevant details. + * @hide + */ +@SystemApi +@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE) +public final class VerificationStatus implements Parcelable { + /** + * The ASL status has not been determined. This happens in situations where the verification + * service is not monitoring ASLs, and means the ASL data in the app is not necessarily bad but + * can't be trusted. + */ + public static final int VERIFIER_STATUS_ASL_UNDEFINED = 0; + + /** + * The app's ASL data is considered to be in a good state. + */ + public static final int VERIFIER_STATUS_ASL_GOOD = 1; + + /** + * There is something bad in the app's ASL data; the user should be warned about this when shown + * the ASL data and/or appropriate decisions made about the use of this data by the platform. + */ + public static final int VERIFIER_STATUS_ASL_BAD = 2; + + /** @hide */ + @IntDef(prefix = {"VERIFIER_STATUS_ASL_"}, value = { + VERIFIER_STATUS_ASL_UNDEFINED, + VERIFIER_STATUS_ASL_GOOD, + VERIFIER_STATUS_ASL_BAD, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface VerifierStatusAsl {} + + private boolean mIsVerified; + private @VerifierStatusAsl int mAslStatus; + @NonNull + private String mFailuresMessage = ""; + + private VerificationStatus() {} + + /** + * @return whether the status is set to verified or not. + */ + public boolean isVerified() { + return mIsVerified; + } + + /** + * @return the failure message associated with the failure status. + */ + @NonNull + public String getFailureMessage() { + return mFailuresMessage; + } + + /** + * @return the asl status. + */ + public @VerifierStatusAsl int getAslStatus() { + return mAslStatus; + } + + /** + * Builder to construct a {@link VerificationStatus} object. + */ + public static final class Builder { + final VerificationStatus mStatus = new VerificationStatus(); + + /** + * Set in the status whether the verification has succeeded or failed. + */ + @NonNull + public Builder setVerified(boolean verified) { + mStatus.mIsVerified = verified; + return this; + } + + /** + * Set a developer-facing failure message to include in the verification failure status. + */ + @NonNull + public Builder setFailureMessage(@NonNull String failureMessage) { + mStatus.mFailuresMessage = failureMessage; + return this; + } + + /** + * Set the ASL status, as defined in {@link VerifierStatusAsl}. + */ + @NonNull + public Builder setAslStatus(@VerifierStatusAsl int aslStatus) { + mStatus.mAslStatus = aslStatus; + return this; + } + + /** + * Build the status object. + */ + @NonNull + public VerificationStatus build() { + return mStatus; + } + } + + private VerificationStatus(Parcel in) { + mIsVerified = in.readBoolean(); + mAslStatus = in.readInt(); + mFailuresMessage = in.readString8(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeBoolean(mIsVerified); + dest.writeInt(mAslStatus); + dest.writeString8(mFailuresMessage); + } + + @Override + public int describeContents() { + return 0; + } + + @NonNull + public static final Creator<VerificationStatus> CREATOR = new Creator<>() { + @Override + public VerificationStatus createFromParcel(@NonNull Parcel in) { + return new VerificationStatus(in); + } + + @Override + public VerificationStatus[] newArray(int size) { + return new VerificationStatus[size]; + } + }; +} diff --git a/core/java/android/content/pm/verify/pkg/VerifierService.java b/core/java/android/content/pm/verify/pkg/VerifierService.java new file mode 100644 index 000000000000..ccf211915326 --- /dev/null +++ b/core/java/android/content/pm/verify/pkg/VerifierService.java @@ -0,0 +1,113 @@ +/* + * 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 android.content.pm.verify.pkg; + +import android.annotation.FlaggedApi; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.SystemApi; +import android.app.Service; +import android.content.Intent; +import android.content.pm.Flags; +import android.content.pm.PackageManager; +import android.os.IBinder; + +/** + * A base service implementation for the verifier agent to implement. + * + * @hide + */ +@SystemApi +@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE) +public abstract class VerifierService extends Service { + /** + * Called when a package name is available for a pending verification, + * giving the verifier opportunity to pre-fetch any relevant information + * that may be needed should a verification for the package be required. + */ + public abstract void onPackageNameAvailable(@NonNull String packageName); + + /** + * Called when a package recently provided via {@link #onPackageNameAvailable} + * is no longer expected to be installed. This is a hint that any pre-fetch or + * cache created as a result of the previous call may be be cleared. + * <p>This method will never be called after {@link #onVerificationRequired} is called for the + * same package. Once a verification is officially requested by + * {@link #onVerificationRequired}, it cannot be cancelled. + * </p> + */ + public abstract void onVerificationCancelled(@NonNull String packageName); + + /** + * Called when an application needs to be verified. Details about the + * verification and actions that can be taken on it will be encapsulated in + * the provided {@link VerificationSession} parameter. + */ + public abstract void onVerificationRequired(@NonNull VerificationSession session); + + /** + * Called when a verification needs to be retried. This can be encountered + * when a prior verification was marked incomplete and the user has indicated + * that they've resolved the issue, or when a timeout is reached, but the + * the system is attempting to retry. Details about the + * verification and actions that can be taken on it will be encapsulated in + * the provided {@link VerificationSession} parameter. + */ + public abstract void onVerificationRetry(@NonNull VerificationSession session); + + /** + * Called in the case that an active verification has failed. Any APIs called + * on the {@link VerificationSession} instance associated with this {@code verificationId} will + * throw an {@link IllegalStateException}. + */ + public abstract void onVerificationTimeout(int verificationId); + + /** + * Called when the verifier service is bound to the system. + */ + public @Nullable IBinder onBind(@Nullable Intent intent) { + if (intent == null || !PackageManager.ACTION_VERIFY_PACKAGE.equals(intent.getAction())) { + return null; + } + return new IVerifierService.Stub() { + @Override + public void onPackageNameAvailable(@NonNull String packageName) { + VerifierService.this.onPackageNameAvailable(packageName); + } + + @Override + public void onVerificationCancelled(@NonNull String packageName) { + VerifierService.this.onVerificationCancelled(packageName); + } + + @Override + public void onVerificationRequired(@NonNull VerificationSession session) { + VerifierService.this.onVerificationRequired(session); + } + + @Override + public void onVerificationRetry(@NonNull VerificationSession session) { + VerifierService.this.onVerificationRetry(session); + } + + @Override + public void onVerificationTimeout(int verificationId) { + VerifierService.this.onVerificationTimeout(verificationId); + } + }; + } +} diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index d35c66ed719e..ed33edecb333 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -8449,6 +8449,29 @@ <permission android:name="android.permission.RESERVED_FOR_TESTING_SIGNATURE" android:protectionLevel="signature"/> + <!-- @SystemApi + @FlaggedApi("android.content.pm.verification_service") + Allows app to be the verification agent to verify packages. + <p>Protection level: signature|privileged + @hide + --> + <permission android:name="android.permission.VERIFICATION_AGENT" + android:protectionLevel="signature|privileged" + android:featureFlag="android.content.pm.verification_service" /> + + <!-- @SystemApi + @FlaggedApi("android.content.pm.verification_service") + Must be required by a privileged {@link android.content.pm.verify.pkg.VerifierService} + to ensure that only the system can bind to it. + This permission should not be held by anything other than the system. + <p>Not for use by third-party applications. </p> + <p>Protection level: signature + @hide + --> + <permission android:name="android.permission.BIND_VERIFICATION_AGENT" + android:protectionLevel="internal" + android:featureFlag="android.content.pm.verification_service" /> + <!-- Attribution for Geofencing service. --> <attribution android:tag="GeofencingService" android:label="@string/geofencing_service"/> <!-- Attribution for Country Detector. --> diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml index 5c0dca2104af..71ae22f6e100 100644 --- a/core/res/res/values/config.xml +++ b/core/res/res/values/config.xml @@ -2309,10 +2309,6 @@ spatial audio is enabled for a newly connected audio device --> <bool name="config_spatial_audio_head_tracking_enabled_default">false</bool> - <!-- Flag indicating whether platform level volume adjustments are enabled for remote sessions - on grouped devices. --> - <bool name="config_volumeAdjustmentForRemoteGroupSessions">true</bool> - <!-- Flag indicating current media Output Switcher version. --> <integer name="config_mediaOutputSwitchDialogVersion">1</integer> diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index 807df1be7fb5..0ccef9123e10 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -5102,8 +5102,6 @@ <java-symbol type="dimen" name="config_wallpaperDimAmount" /> - <java-symbol type="bool" name="config_volumeAdjustmentForRemoteGroupSessions" /> - <java-symbol type="integer" name="config_mediaOutputSwitchDialogVersion" /> <!-- List of shared library packages that should be loaded by the classloader after the diff --git a/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java b/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java new file mode 100644 index 000000000000..987f68d4f9e1 --- /dev/null +++ b/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java @@ -0,0 +1,155 @@ +/* + * 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 android.content.pm.verify; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.pm.SharedLibraryInfo; +import android.content.pm.SigningInfo; +import android.content.pm.VersionedPackage; +import android.content.pm.verify.pkg.IVerificationSessionCallback; +import android.content.pm.verify.pkg.IVerificationSessionInterface; +import android.content.pm.verify.pkg.VerificationSession; +import android.content.pm.verify.pkg.VerificationStatus; +import android.net.Uri; +import android.os.Parcel; +import android.os.PersistableBundle; +import android.platform.test.annotations.Presubmit; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Presubmit +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VerificationSessionTest { + private static final int TEST_ID = 100; + private static final int TEST_INSTALL_SESSION_ID = 33; + private static final String TEST_PACKAGE_NAME = "com.foo"; + private static final Uri TEST_PACKAGE_URI = Uri.parse("test://test"); + private static final SigningInfo TEST_SIGNING_INFO = new SigningInfo(); + private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO1 = + new SharedLibraryInfo("sharedLibPath1", TEST_PACKAGE_NAME, + Collections.singletonList("path1"), "sharedLib1", 101, + SharedLibraryInfo.TYPE_DYNAMIC, new VersionedPackage(TEST_PACKAGE_NAME, 1), + null, null, false); + private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO2 = + new SharedLibraryInfo("sharedLibPath2", TEST_PACKAGE_NAME, + Collections.singletonList("path2"), "sharedLib2", 102, + SharedLibraryInfo.TYPE_DYNAMIC, + new VersionedPackage(TEST_PACKAGE_NAME, 2), null, null, false); + private static final long TEST_TIMEOUT_TIME = System.currentTimeMillis(); + private static final long TEST_EXTEND_TIME = 2000L; + private static final String TEST_KEY = "test key"; + private static final String TEST_VALUE = "test value"; + + private final ArrayList<SharedLibraryInfo> mTestDeclaredLibraries = new ArrayList<>(); + private final PersistableBundle mTestExtensionParams = new PersistableBundle(); + @Mock + private IVerificationSessionInterface mTestSessionInterface; + @Mock + private IVerificationSessionCallback mTestCallback; + private VerificationSession mTestSession; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO1); + mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO2); + mTestExtensionParams.putString(TEST_KEY, TEST_VALUE); + mTestSession = new VerificationSession(TEST_ID, TEST_INSTALL_SESSION_ID, + TEST_PACKAGE_NAME, TEST_PACKAGE_URI, TEST_SIGNING_INFO, mTestDeclaredLibraries, + mTestExtensionParams, mTestSessionInterface, mTestCallback); + } + + @Test + public void testParcel() { + Parcel parcel = Parcel.obtain(); + mTestSession.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + VerificationSession sessionFromParcel = + VerificationSession.CREATOR.createFromParcel(parcel); + assertThat(sessionFromParcel.getId()).isEqualTo(TEST_ID); + assertThat(sessionFromParcel.getInstallSessionId()).isEqualTo(TEST_INSTALL_SESSION_ID); + assertThat(sessionFromParcel.getPackageName()).isEqualTo(TEST_PACKAGE_NAME); + assertThat(sessionFromParcel.getStagedPackageUri()).isEqualTo(TEST_PACKAGE_URI); + assertThat(sessionFromParcel.getSigningInfo().getSigningDetails()) + .isEqualTo(TEST_SIGNING_INFO.getSigningDetails()); + List<SharedLibraryInfo> declaredLibrariesFromParcel = + sessionFromParcel.getDeclaredLibraries(); + assertThat(declaredLibrariesFromParcel).hasSize(2); + // SharedLibraryInfo doesn't have a "equals" method, so we have to check it indirectly + assertThat(declaredLibrariesFromParcel.getFirst().toString()) + .isEqualTo(TEST_SHARED_LIBRARY_INFO1.toString()); + assertThat(declaredLibrariesFromParcel.get(1).toString()) + .isEqualTo(TEST_SHARED_LIBRARY_INFO2.toString()); + // We can't directly test with PersistableBundle.equals() because the parceled bundle's + // structure is different, but all the key/value pairs should be preserved as before. + assertThat(sessionFromParcel.getExtensionParams().getString(TEST_KEY)) + .isEqualTo(mTestExtensionParams.getString(TEST_KEY)); + } + + @Test + public void testInterface() throws Exception { + when(mTestSessionInterface.getTimeoutTime(anyInt())).thenAnswer(i -> TEST_TIMEOUT_TIME); + when(mTestSessionInterface.extendTimeRemaining(anyInt(), anyLong())).thenAnswer( + i -> i.getArguments()[1]); + + assertThat(mTestSession.getTimeoutTime()).isEqualTo(TEST_TIMEOUT_TIME); + verify(mTestSessionInterface, times(1)).getTimeoutTime(eq(TEST_ID)); + assertThat(mTestSession.extendTimeRemaining(TEST_EXTEND_TIME)).isEqualTo(TEST_EXTEND_TIME); + verify(mTestSessionInterface, times(1)).extendTimeRemaining( + eq(TEST_ID), eq(TEST_EXTEND_TIME)); + } + + @Test + public void testCallback() throws Exception { + PersistableBundle response = new PersistableBundle(); + response.putString("test key", "test value"); + final VerificationStatus status = + new VerificationStatus.Builder().setVerified(true).build(); + mTestSession.reportVerificationComplete(status); + verify(mTestCallback, times(1)).reportVerificationComplete( + eq(TEST_ID), eq(status)); + mTestSession.reportVerificationComplete(status, response); + verify(mTestCallback, times(1)) + .reportVerificationCompleteWithExtensionResponse( + eq(TEST_ID), eq(status), eq(response)); + + final int reason = VerificationSession.VERIFICATION_INCOMPLETE_UNKNOWN; + mTestSession.reportVerificationIncomplete(reason); + verify(mTestCallback, times(1)).reportVerificationIncomplete( + eq(TEST_ID), eq(reason)); + } +} diff --git a/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java b/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java new file mode 100644 index 000000000000..67d407a72925 --- /dev/null +++ b/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java @@ -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 android.content.pm.verify; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.pm.verify.pkg.VerificationStatus; +import android.os.Parcel; +import android.os.PersistableBundle; +import android.platform.test.annotations.Presubmit; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@Presubmit +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VerificationStatusTest { + private static final boolean TEST_VERIFIED = true; + private static final int TEST_ASL_STATUS = VerificationStatus.VERIFIER_STATUS_ASL_GOOD; + private static final String TEST_FAILURE_MESSAGE = "test test"; + private static final String TEST_KEY = "test key"; + private static final String TEST_VALUE = "test value"; + private final PersistableBundle mTestExtras = new PersistableBundle(); + private VerificationStatus mStatus; + + @Before + public void setUpWithBuilder() { + mTestExtras.putString(TEST_KEY, TEST_VALUE); + mStatus = new VerificationStatus.Builder() + .setAslStatus(TEST_ASL_STATUS) + .setFailureMessage(TEST_FAILURE_MESSAGE) + .setVerified(TEST_VERIFIED) + .build(); + } + + @Test + public void testGetters() { + assertThat(mStatus.isVerified()).isEqualTo(TEST_VERIFIED); + assertThat(mStatus.getAslStatus()).isEqualTo(TEST_ASL_STATUS); + assertThat(mStatus.getFailureMessage()).isEqualTo(TEST_FAILURE_MESSAGE); + } + + @Test + public void testParcel() { + Parcel parcel = Parcel.obtain(); + mStatus.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + VerificationStatus statusFromParcel = VerificationStatus.CREATOR.createFromParcel(parcel); + assertThat(statusFromParcel.isVerified()).isEqualTo(TEST_VERIFIED); + assertThat(statusFromParcel.getAslStatus()).isEqualTo(TEST_ASL_STATUS); + assertThat(statusFromParcel.getFailureMessage()).isEqualTo(TEST_FAILURE_MESSAGE); + } +} diff --git a/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java b/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java new file mode 100644 index 000000000000..7f73a1eb4b48 --- /dev/null +++ b/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java @@ -0,0 +1,92 @@ +/* + * 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 android.content.pm.verify; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.SigningInfo; +import android.content.pm.verify.pkg.IVerifierService; +import android.content.pm.verify.pkg.VerificationSession; +import android.content.pm.verify.pkg.VerifierService; +import android.net.Uri; +import android.os.PersistableBundle; +import android.platform.test.annotations.Presubmit; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.Mockito; + +import java.util.ArrayList; + +@Presubmit +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VerifierServiceTest { + private static final int TEST_ID = 100; + private static final int TEST_INSTALL_SESSION_ID = 33; + private static final String TEST_PACKAGE_NAME = "com.foo"; + private static final Uri TEST_PACKAGE_URI = Uri.parse("test://test"); + private static final SigningInfo TEST_SIGNING_INFO = new SigningInfo(); + private VerifierService mService; + private VerificationSession mSession; + + @Before + public void setUp() { + mService = Mockito.mock(VerifierService.class, Answers.CALLS_REAL_METHODS); + mSession = new VerificationSession(TEST_ID, TEST_INSTALL_SESSION_ID, + TEST_PACKAGE_NAME, TEST_PACKAGE_URI, TEST_SIGNING_INFO, + new ArrayList<>(), + new PersistableBundle(), null, null); + } + + @Test + public void testBind() throws Exception { + Intent intent = Mockito.mock(Intent.class); + when(intent.getAction()).thenReturn(PackageManager.ACTION_VERIFY_PACKAGE); + IVerifierService binder = + (IVerifierService) mService.onBind(intent); + assertThat(binder).isNotNull(); + binder.onPackageNameAvailable(TEST_PACKAGE_NAME); + verify(mService).onPackageNameAvailable(eq(TEST_PACKAGE_NAME)); + binder.onVerificationCancelled(TEST_PACKAGE_NAME); + verify(mService).onVerificationCancelled(eq(TEST_PACKAGE_NAME)); + binder.onVerificationRequired(mSession); + verify(mService).onVerificationRequired(eq(mSession)); + binder.onVerificationRetry(mSession); + verify(mService).onVerificationRetry(eq(mSession)); + binder.onVerificationTimeout(TEST_ID); + verify(mService).onVerificationTimeout(eq(TEST_ID)); + } + + @Test + public void testBindFailsWithWrongIntent() { + Intent intent = Mockito.mock(Intent.class); + when(intent.getAction()).thenReturn(Intent.ACTION_SEND); + assertThat(mService.onBind(intent)).isNull(); + } +} 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 955fe83d34ee..985224e30a51 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 @@ -350,8 +350,17 @@ class DesktopModeTaskRepository ( /** Minimizes the task for [taskId] and [displayId] */ fun minimizeTask(displayId: Int, taskId: Int) { - logD("Minimize Task: display=%d, task=%d", displayId, taskId) - desktopTaskDataByDisplayId.getOrCreate(displayId).minimizedTasks.add(taskId) + if (displayId == INVALID_DISPLAY) { + // When a task vanishes it doesn't have a displayId. Find the display of the task and + // mark it as minimized. + getDisplayIdForTask(taskId)?.let { + minimizeTask(it, taskId) + } ?: logW("Minimize task: No display id found for task: taskId=%d", taskId) + } else { + logD("Minimize Task: display=%d, task=%d", displayId, taskId) + desktopTaskDataByDisplayId.getOrCreate(displayId).minimizedTasks.add(taskId) + } + if (Flags.enableDesktopWindowingPersistence()) { updatePersistentRepository(displayId) } 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 125805c14321..b8bb73ba4148 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 @@ -44,7 +44,6 @@ import android.view.Display.DEFAULT_DISPLAY import android.view.DragEvent import android.view.SurfaceControl import android.view.WindowManager.TRANSIT_CHANGE -import android.view.WindowManager.TRANSIT_CLOSE import android.view.WindowManager.TRANSIT_NONE import android.view.WindowManager.TRANSIT_OPEN import android.view.WindowManager.TRANSIT_TO_FRONT @@ -550,7 +549,29 @@ class DesktopTasksController( /** Move a task to the front */ fun moveTaskToFront(taskId: Int) { - shellTaskOrganizer.getRunningTaskInfo(taskId)?.let { task -> moveTaskToFront(task) } + val task = shellTaskOrganizer.getRunningTaskInfo(taskId) + if (task == null) moveBackgroundTaskToFront(taskId) else moveTaskToFront(task) + } + + /** + * Launch a background task in desktop. Note that this should be used when we are already in + * desktop. If outside of desktop and want to launch a background task in desktop, use + * [moveBackgroundTaskToDesktop] instead. + */ + private fun moveBackgroundTaskToFront(taskId: Int) { + logV("moveBackgroundTaskToFront taskId=%s", taskId) + val wct = WindowContainerTransaction() + // TODO: b/342378842 - Instead of using default display, support multiple displays + val taskToMinimize: RunningTaskInfo? = + addAndGetMinimizeChangesIfNeeded(DEFAULT_DISPLAY, wct, taskId) + wct.startTask( + taskId, + ActivityOptions.makeBasic().apply { + launchWindowingMode = WINDOWING_MODE_FREEFORM + }.toBundle(), + ) + val transition = transitions.startTransition(TRANSIT_OPEN, wct, null /* handler */) + addPendingMinimizeTransition(transition, taskToMinimize) } /** Move a task to the front */ @@ -558,7 +579,8 @@ class DesktopTasksController( logV("moveTaskToFront taskId=%s", taskInfo.taskId) val wct = WindowContainerTransaction() wct.reorder(taskInfo.token, true) - val taskToMinimize = addAndGetMinimizeChangesIfNeeded(taskInfo.displayId, wct, taskInfo) + val taskToMinimize = + addAndGetMinimizeChangesIfNeeded(taskInfo.displayId, wct, taskInfo.taskId) if (Transitions.ENABLE_SHELL_TRANSITIONS) { val transition = transitions.startTransition(TRANSIT_TO_FRONT, wct, null /* handler */) addPendingMinimizeTransition(transition, taskToMinimize) @@ -1254,7 +1276,7 @@ class DesktopTasksController( } // Desktop Mode is showing and we're launching a new Task - we might need to minimize // a Task. - val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task) + val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task.taskId) if (taskToMinimize != null) { addPendingMinimizeTransition(transition, taskToMinimize) return wct @@ -1280,7 +1302,8 @@ class DesktopTasksController( // Desktop Mode is already showing and we're launching a new Task - we might need to // minimize another Task. - val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task) + val taskToMinimize = + addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task.taskId) addPendingMinimizeTransition(transition, taskToMinimize) } } @@ -1313,14 +1336,11 @@ class DesktopTasksController( // Remove wallpaper activity when the last active task is removed removeWallpaperActivity(wct) } - taskRepository.addClosingTask(task.displayId, task.taskId) - // If a CLOSE is triggered on a desktop task, remove the task. - if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue() && - taskRepository.isVisibleTask(task.taskId) && - transitionType == TRANSIT_CLOSE - ) { - wct.removeTask(task.token) + + if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue()) { + taskRepository.addClosingTask(task.displayId, task.taskId) } + taskbarDesktopTaskListener?.onTaskbarCornerRoundingUpdate( doesAnyTaskRequireTaskbarRounding( task.displayId, @@ -1425,12 +1445,12 @@ class DesktopTasksController( private fun addAndGetMinimizeChangesIfNeeded( displayId: Int, wct: WindowContainerTransaction, - newTaskInfo: RunningTaskInfo + newTaskId: Int ): RunningTaskInfo? { if (!desktopTasksLimiter.isPresent) return null return desktopTasksLimiter .get() - .addAndGetMinimizeTaskChangesIfNeeded(displayId, wct, newTaskInfo) + .addAndGetMinimizeTaskChangesIfNeeded(displayId, wct, newTaskId) } private fun addPendingMinimizeTransition( diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt index d84349b1ce1f..7e0741f1f859 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt @@ -208,15 +208,15 @@ class DesktopTasksLimiter ( fun addAndGetMinimizeTaskChangesIfNeeded( displayId: Int, wct: WindowContainerTransaction, - newFrontTaskInfo: RunningTaskInfo, + newFrontTaskId: Int, ): RunningTaskInfo? { ProtoLog.v( ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE, "DesktopTasksLimiter: addMinimizeBackTaskChangesIfNeeded, newFrontTask=%d", - newFrontTaskInfo.taskId) + newFrontTaskId) val newTaskListOrderedFrontToBack = createOrderedTaskListWithGivenTaskInFront( taskRepository.getActiveNonMinimizedOrderedTasks(displayId), - newFrontTaskInfo.taskId) + newFrontTaskId) val taskToMinimize = getTaskToMinimizeIfNeeded(newTaskListOrderedFrontToBack) if (taskToMinimize != null) { wct.reorder(taskToMinimize.token, false /* onTop */) diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java index 83cc18baf6cc..7f7f10519bb1 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java @@ -24,6 +24,7 @@ import android.app.ActivityManager.RunningTaskInfo; import android.content.Context; import android.util.SparseArray; import android.view.SurfaceControl; +import android.window.flags.DesktopModeFlags; import com.android.internal.protolog.ProtoLog; import com.android.wm.shell.ShellTaskOrganizer; @@ -121,7 +122,16 @@ public class FreeformTaskListener implements ShellTaskOrganizer.TaskListener, if (DesktopModeStatus.canEnterDesktopMode(mContext)) { mDesktopModeTaskRepository.ifPresent(repository -> { - repository.removeFreeformTask(taskInfo.displayId, taskInfo.taskId); + // TODO: b/370038902 - Handle Activity#finishAndRemoveTask. + if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue() + || repository.isClosingTask(taskInfo.taskId)) { + // A task that's vanishing should be removed: + // - If it's closed by the X button which means it's marked as a closing task. + repository.removeFreeformTask(taskInfo.displayId, taskInfo.taskId); + } else { + repository.updateTaskVisibility(taskInfo.displayId, taskInfo.taskId, false); + repository.minimizeTask(taskInfo.displayId, taskInfo.taskId); + } }); } mWindowDecorationViewModel.onTaskVanished(taskInfo); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java index 8077aeebf27f..f7ed1dd4606b 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java @@ -52,7 +52,6 @@ import android.util.ArrayMap; import android.util.IntArray; import android.util.Pair; import android.util.Slog; -import android.view.Display; import android.view.RemoteAnimationTarget; import android.view.SurfaceControl; import android.window.PictureInPictureSurfaceTransaction; @@ -910,6 +909,14 @@ public class RecentsTransitionHandler implements Transitions.TransitionHandler, "task #" + taskInfo.taskId + " is always_on_top"); return; } + if (TransitionUtil.isClosingType(change.getMode()) + && taskInfo != null && taskInfo.lastParentTaskIdBeforePip > 0) { + // Pinned task is closing as a side effect of the removal of its original Task, + // such transition should be handled by PiP. So cancel the merge here. + cancel(false /* toHome */, false /* withScreenshots */, + "task #" + taskInfo.taskId + " is removed with its original parent"); + return; + } final boolean isRootTask = taskInfo != null && TransitionInfo.isIndependent(change, info); final boolean isRecentsTask = mRecentsTask != null 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 794f9d819f4b..97ceecc3662d 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 @@ -820,6 +820,18 @@ class DesktopModeTaskRepositoryTest : ShellTestCase() { } @Test + fun minimizeTask_withInvalidDisplay_minimizesCorrectTask() { + repo.addActiveTask(displayId = DEFAULT_DISPLAY, taskId = 0) + repo.addOrMoveFreeformTaskToTop(displayId = DEFAULT_DISPLAY, taskId = 0) + + repo.minimizeTask(displayId = INVALID_DISPLAY, taskId = 0) + + assertThat(repo.isMinimizedTask(taskId = 0)).isTrue() + assertThat(repo.isMinimizedTask(taskId = 1)).isFalse() + assertThat(repo.isMinimizedTask(taskId = 2)).isFalse() + } + + @Test fun unminimizeTask_unminimizesTask() { repo.minimizeTask(displayId = 0, taskId = 0) diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt index 8870846efed4..2ddb1acf7300 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt @@ -1350,6 +1350,32 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test + fun moveTaskToFront_backgroundTask_launchesTask() { + val task = createTaskInfo(1) + whenever(shellTaskOrganizer.getRunningTaskInfo(anyInt())).thenReturn(null) + + controller.moveTaskToFront(task.taskId) + + val wct = getLatestWct(type = TRANSIT_OPEN) + assertThat(wct.hierarchyOps).hasSize(1) + wct.assertLaunchTaskAt(0, task.taskId, WINDOWING_MODE_FREEFORM) + } + + @Test + fun moveTaskToFront_backgroundTaskBringsTasksOverLimit_minimizesBackTask() { + val freeformTasks = (1..MAX_TASK_LIMIT).map { _ -> setUpFreeformTask() } + val task = createTaskInfo(1001) + whenever(shellTaskOrganizer.getRunningTaskInfo(task.taskId)).thenReturn(null) + + controller.moveTaskToFront(task.taskId) + + val wct = getLatestWct(type = TRANSIT_OPEN) + assertThat(wct.hierarchyOps.size).isEqualTo(2) // launch + minimize + wct.assertReorderAt(0, freeformTasks[0], toTop = false) + wct.assertLaunchTaskAt(1, task.taskId, WINDOWING_MODE_FREEFORM) + } + + @Test fun moveToNextDisplay_noOtherDisplays() { whenever(rootTaskDisplayAreaOrganizer.displayIds).thenReturn(intArrayOf(DEFAULT_DISPLAY)) val task = setUpFreeformTask(displayId = DEFAULT_DISPLAY) @@ -2075,11 +2101,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION, - ) - fun handleRequest_backTransition_singleTaskNoToken_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_backTransition_singleTaskNoToken_noWallpaper_doesNotHandle() { val task = setUpFreeformTask() val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK)) @@ -2112,8 +2135,7 @@ class DesktopTasksControllerTest : ShellTestCase() { @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_backTransition_singleTaskNoToken_noBackNav_doesNotHandle() { + fun handleRequest_backTransition_singleTaskNoToken_doesNotHandle() { val task = setUpFreeformTask() val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK)) @@ -2122,11 +2144,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_backTransition_singleTaskWithToken_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_backTransition_singleTaskWithToken_noWallpaper_doesNotHandle() { val task = setUpFreeformTask() taskRepository.wallpaperActivityToken = MockToken().token() @@ -2149,11 +2168,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_backTransition_multipleTasks_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_backTransition_multipleTasks_noWallpaper_doesNotHandle() { val task1 = setUpFreeformTask() setUpFreeformTask() @@ -2165,7 +2181,7 @@ class DesktopTasksControllerTest : ShellTestCase() { @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - fun handleRequest_backTransition_multipleTasks_noBackNav_doesNotHandle() { + fun handleRequest_backTransition_multipleTasks_doesNotHandle() { val task1 = setUpFreeformTask() setUpFreeformTask() @@ -2211,11 +2227,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_backTransition_nonMinimizadTask_withWallpaper_withBackNav_removesWallpaper() { + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_backTransition_nonMinimizadTask_withWallpaper_removesWallpaper() { val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val wallpaperToken = MockToken().token() @@ -2231,11 +2244,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_singleTaskNoToken_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_closeTransition_singleTaskNoToken_noWallpaper_doesNotHandle() { val task = setUpFreeformTask() val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE)) @@ -2244,22 +2254,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_singleTaskNoToken_withWallpaper_withBackNav_removesTask() { - val task = setUpFreeformTask() - - val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE)) - - assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, task.token) - } - - @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_closeTransition_singleTaskNoToken_noBackNav_doesNotHandle() { + fun handleRequest_closeTransition_singleTaskNoToken_doesNotHandle() { val task = setUpFreeformTask() val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE)) @@ -2268,11 +2264,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_singleTaskWithToken_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) + fun handleRequest_closeTransition_singleTaskWithToken_noWallpaper_doesNotHandle() { val task = setUpFreeformTask() taskRepository.wallpaperActivityToken = MockToken().token() @@ -2282,26 +2275,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_singleTaskWithToken_removesWallpaperAndTask() { - val task = setUpFreeformTask() - val wallpaperToken = MockToken().token() - - taskRepository.wallpaperActivityToken = wallpaperToken - val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE)) - - // Should create remove wallpaper transaction - assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken) - result.assertRemoveAt(index = 1, task.token) - } - - @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_closeTransition_singleTaskWithToken_withWallpaper_noBackNav_removesWallpaper() { + fun handleRequest_closeTransition_singleTaskWithToken_withWallpaper_removesWallpaper() { val task = setUpFreeformTask() val wallpaperToken = MockToken().token() @@ -2313,11 +2288,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @DisableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_multipleTasks_noWallpaper_noBackNav_doesNotHandle() { + @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) + fun handleRequest_closeTransition_multipleTasks_noWallpaper_doesNotHandle() { val task1 = setUpFreeformTask() setUpFreeformTask() @@ -2328,25 +2300,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_multipleTasks_withWallpaper_withBackNav_removesTask() { - val task1 = setUpFreeformTask() - setUpFreeformTask() - - taskRepository.wallpaperActivityToken = MockToken().token() - val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE)) - - assertNotNull(result, "Should handle request") - result.assertRemoveAt(index = 0, task1.token) - } - - @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_closeTransition_multipleTasksFlagEnabled_noBackNav_doesNotHandle() { + fun handleRequest_closeTransition_multipleTasksFlagEnabled_doesNotHandle() { val task1 = setUpFreeformTask() setUpFreeformTask() @@ -2357,28 +2312,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_multipleTasksSingleNonClosing_removesWallpaperAndTask() { - val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) - val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) - val wallpaperToken = MockToken().token() - - taskRepository.wallpaperActivityToken = wallpaperToken - taskRepository.addClosingTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId) - val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE)) - - // Should create remove wallpaper transaction - assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken) - result.assertRemoveAt(index = 1, task1.token) - } - - @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_closeTransition_multipleTasksSingleNonClosing_noBackNav_removesWallpaper() { + fun handleRequest_closeTransition_multipleTasksSingleNonClosing_removesWallpaper() { val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val wallpaperToken = MockToken().token() @@ -2392,28 +2327,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_multipleTasksOneNonMinimized_removesWallpaperAndTask() { - val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) - val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) - val wallpaperToken = MockToken().token() - - taskRepository.wallpaperActivityToken = wallpaperToken - taskRepository.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId) - val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE)) - - // Should create remove wallpaper transaction - assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken) - result.assertRemoveAt(index = 1, task1.token) - } - - @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY) - @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) - fun handleRequest_closeTransition_multipleTasksSingleNonMinimized_noBackNav_removesWallpaper() { + fun handleRequest_closeTransition_multipleTasksSingleNonMinimized_removesWallpaper() { val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val wallpaperToken = MockToken().token() @@ -2427,11 +2342,8 @@ class DesktopTasksControllerTest : ShellTestCase() { } @Test - @EnableFlags( - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY, - Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION - ) - fun handleRequest_closeTransition_minimizadTask_withWallpaper_withBackNav_removesWallpaper() { + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,) + fun handleRequest_closeTransition_minimizadTask_withWallpaper_removesWallpaper() { val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY) val wallpaperToken = MockToken().token() diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt index 045e07796cb8..bc5ae97a55ea 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt @@ -291,7 +291,7 @@ class DesktopTasksLimiterTest : ShellTestCase() { desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded( displayId = DEFAULT_DISPLAY, wct = wct, - newFrontTaskInfo = setUpFreeformTask()) + newFrontTaskId = setUpFreeformTask().taskId) assertThat(minimizedTaskId).isNull() assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added @@ -307,7 +307,7 @@ class DesktopTasksLimiterTest : ShellTestCase() { desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded( displayId = DEFAULT_DISPLAY, wct = wct, - newFrontTaskInfo = setUpFreeformTask()) + newFrontTaskId = setUpFreeformTask().taskId) assertThat(minimizedTaskId).isEqualTo(tasks.first()) assertThat(wct.hierarchyOps.size).isEqualTo(1) @@ -325,7 +325,7 @@ class DesktopTasksLimiterTest : ShellTestCase() { desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded( displayId = 0, wct = wct, - newFrontTaskInfo = setUpFreeformTask()) + newFrontTaskId = setUpFreeformTask().taskId) assertThat(minimizedTaskId).isNull() assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java index 763d0153071e..3b2c7e6eaf99 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java @@ -18,15 +18,19 @@ package com.android.wm.shell.freeform; import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM; import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN; +import static android.view.Display.INVALID_DISPLAY; import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn; import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession; +import static com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.app.ActivityManager; +import android.platform.test.annotations.EnableFlags; import android.view.SurfaceControl; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -139,6 +143,40 @@ public final class FreeformTaskListenerTests extends ShellTestCase { verify(mLaunchAdjacentController).setLaunchAdjacentEnabled(true); } + @Test + @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) + public void onTaskVanished_nonClosingTask_isMinimized() { + ActivityManager.RunningTaskInfo task = new TestRunningTaskInfoBuilder() + .setWindowingMode(WINDOWING_MODE_FREEFORM).build(); + task.isVisible = true; + + mFreeformTaskListener.onTaskAppeared(task, mMockSurfaceControl); + + task.isVisible = false; + task.displayId = INVALID_DISPLAY; + mFreeformTaskListener.onTaskVanished(task); + + verify(mDesktopModeTaskRepository).minimizeTask(task.displayId, task.taskId); + } + + @Test + @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION) + public void onTaskVanished_closingTask_isNotMinimized() { + ActivityManager.RunningTaskInfo task = new TestRunningTaskInfoBuilder() + .setWindowingMode(WINDOWING_MODE_FREEFORM).build(); + task.isVisible = true; + + mFreeformTaskListener.onTaskAppeared(task, mMockSurfaceControl); + + when(mDesktopModeTaskRepository.isClosingTask(task.taskId)).thenReturn(true); + task.isVisible = false; + task.displayId = INVALID_DISPLAY; + mFreeformTaskListener.onTaskVanished(task); + + verify(mDesktopModeTaskRepository, never()).minimizeTask(task.displayId, task.taskId); + verify(mDesktopModeTaskRepository).removeFreeformTask(task.displayId, task.taskId); + } + @After public void tearDown() { mMockitoSession.finishMocking(); diff --git a/media/java/android/media/RoutingSessionInfo.java b/media/java/android/media/RoutingSessionInfo.java index 9899e4ec388d..83a4dd5a682a 100644 --- a/media/java/android/media/RoutingSessionInfo.java +++ b/media/java/android/media/RoutingSessionInfo.java @@ -22,7 +22,6 @@ import android.annotation.FlaggedApi; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; -import android.content.res.Resources; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; @@ -57,8 +56,6 @@ public final class RoutingSessionInfo implements Parcelable { } }; - private static final String TAG = "RoutingSessionInfo"; - private static final String KEY_GROUP_ROUTE = "androidx.mediarouter.media.KEY_GROUP_ROUTE"; private static final String KEY_VOLUME_HANDLING = "volumeHandling"; @@ -142,15 +139,7 @@ public final class RoutingSessionInfo implements Parcelable { mVolume = builder.mVolume; mIsSystemSession = builder.mIsSystemSession; - - boolean volumeAdjustmentForRemoteGroupSessions = Resources.getSystem().getBoolean( - com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions); - mVolumeHandling = - defineVolumeHandling( - mIsSystemSession, - builder.mVolumeHandling, - mSelectedRoutes, - volumeAdjustmentForRemoteGroupSessions); + mVolumeHandling = builder.mVolumeHandling; mControlHints = updateVolumeHandlingInHints(builder.mControlHints, mVolumeHandling); mTransferReason = builder.mTransferReason; @@ -207,20 +196,6 @@ public final class RoutingSessionInfo implements Parcelable { return controlHints; } - @MediaRoute2Info.PlaybackVolume - private static int defineVolumeHandling( - boolean isSystemSession, - @MediaRoute2Info.PlaybackVolume int volumeHandling, - List<String> selectedRoutes, - boolean volumeAdjustmentForRemoteGroupSessions) { - if (!isSystemSession - && !volumeAdjustmentForRemoteGroupSessions - && selectedRoutes.size() > 1) { - return MediaRoute2Info.PLAYBACK_VOLUME_FIXED; - } - return volumeHandling; - } - @NonNull private static String ensureString(@Nullable String str) { return str != null ? str : ""; diff --git a/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java b/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java index 3955ff068d94..5f5058d79545 100644 --- a/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java +++ b/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java @@ -18,8 +18,6 @@ package com.android.mediaroutertest; import static com.google.common.truth.Truth.assertThat; -import android.content.res.Resources; -import android.media.MediaRoute2Info; import android.media.RoutingSessionInfo; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -95,24 +93,4 @@ public class RoutingSessionInfoTest { assertThat(sessionInfoWithProviderId2.getTransferableRoutes()) .isEqualTo(sessionInfoWithProviderId.getTransferableRoutes()); } - - @Test - public void testGetVolumeHandlingGroupSession() { - RoutingSessionInfo sessionInfo = new RoutingSessionInfo.Builder( - TEST_ID, TEST_CLIENT_PACKAGE_NAME) - .setName(TEST_NAME) - .addSelectedRoute(TEST_ROUTE_ID_0) - .addSelectedRoute(TEST_ROUTE_ID_2) - .setVolumeHandling(MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE) - .build(); - - boolean volumeAdjustmentForRemoteGroupSessions = Resources.getSystem().getBoolean( - com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions); - - int expectedResult = volumeAdjustmentForRemoteGroupSessions - ? MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE : - MediaRoute2Info.PLAYBACK_VOLUME_FIXED; - - assertThat(sessionInfo.getVolumeHandling()).isEqualTo(expectedResult); - } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt index dc9e267cb0e1..56de096effce 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt @@ -132,7 +132,6 @@ fun SceneContainer( state = state, modifier = modifier.fillMaxSize(), swipeSourceDetector = viewModel.edgeDetector, - gestureFilter = viewModel::shouldFilterGesture, ) { sceneByKey.forEach { (sceneKey, scene) -> scene( diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt index c8adac064690..5fa5db880cce 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt @@ -124,10 +124,6 @@ internal class DraggableHandlerImpl( overSlop: Float, pointersDown: Int, ): DragController { - if (startedPosition != null && layoutImpl.gestureFilter(startedPosition)) { - return NoOpDragController - } - if (overSlop == 0f) { val oldDragController = dragController check(oldDragController != null && oldDragController.isDrivingTransition) { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt index dc3135ddbf54..5ddc28485ac6 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt @@ -42,10 +42,8 @@ import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.ObserverModifierNode import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.currentValueOf -import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.Velocity @@ -79,7 +77,6 @@ import kotlinx.coroutines.launch @Stable internal fun Modifier.multiPointerDraggable( orientation: Orientation, - enabled: () -> Boolean, startDragImmediately: (startedPosition: Offset) -> Boolean, onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, onFirstPointerDown: () -> Unit = {}, @@ -89,7 +86,6 @@ internal fun Modifier.multiPointerDraggable( this.then( MultiPointerDraggableElement( orientation, - enabled, startDragImmediately, onDragStarted, onFirstPointerDown, @@ -100,7 +96,6 @@ internal fun Modifier.multiPointerDraggable( private data class MultiPointerDraggableElement( private val orientation: Orientation, - private val enabled: () -> Boolean, private val startDragImmediately: (startedPosition: Offset) -> Boolean, private val onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, @@ -111,7 +106,6 @@ private data class MultiPointerDraggableElement( override fun create(): MultiPointerDraggableNode = MultiPointerDraggableNode( orientation = orientation, - enabled = enabled, startDragImmediately = startDragImmediately, onDragStarted = onDragStarted, onFirstPointerDown = onFirstPointerDown, @@ -121,7 +115,6 @@ private data class MultiPointerDraggableElement( override fun update(node: MultiPointerDraggableNode) { node.orientation = orientation - node.enabled = enabled node.startDragImmediately = startDragImmediately node.onDragStarted = onDragStarted node.onFirstPointerDown = onFirstPointerDown @@ -131,7 +124,6 @@ private data class MultiPointerDraggableElement( internal class MultiPointerDraggableNode( orientation: Orientation, - enabled: () -> Boolean, var startDragImmediately: (startedPosition: Offset) -> Boolean, var onDragStarted: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController, @@ -142,21 +134,10 @@ internal class MultiPointerDraggableNode( DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode, - ObserverModifierNode, SpaceVectorConverter { private val pointerTracker = delegate(SuspendingPointerInputModifierNode { pointerTracker() }) private val pointerInput = delegate(SuspendingPointerInputModifierNode { pointerInput() }) private val velocityTracker = VelocityTracker() - private var previousEnabled: Boolean = false - - var enabled: () -> Boolean = enabled - set(value) { - // Reset the pointer input whenever enabled changed. - if (value != field) { - field = value - pointerInput.resetPointerInputHandler() - } - } private var converter = SpaceVectorConverter(orientation) @@ -178,21 +159,6 @@ internal class MultiPointerDraggableNode( } } - override fun onAttach() { - previousEnabled = enabled() - onObservedReadsChanged() - } - - override fun onObservedReadsChanged() { - observeReads { - val newEnabled = enabled() - if (newEnabled != previousEnabled) { - pointerInput.resetPointerInputHandler() - } - previousEnabled = newEnabled - } - } - override fun onCancelPointerInput() { pointerTracker.onCancelPointerInput() pointerInput.onCancelPointerInput() @@ -254,9 +220,7 @@ internal class MultiPointerDraggableNode( velocityTracker.resetTracking() velocityTracker.addPointerInputChange(firstPointerDown) startedPosition = firstPointerDown.position - if (enabled()) { - onFirstPointerDown() - } + onFirstPointerDown() } // Changes with at least one pointer @@ -295,10 +259,6 @@ internal class MultiPointerDraggableNode( } private suspend fun PointerInputScope.pointerInput() { - if (!enabled()) { - return - } - val currentContext = currentCoroutineContext() awaitPointerEventScope { while (currentContext.isActive) { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt index 6e89814a2dc2..cec888380513 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt @@ -47,9 +47,6 @@ import androidx.compose.ui.unit.LayoutDirection * @param state the state of this layout. * @param swipeSourceDetector the edge detector used to detect which edge a swipe is started from, * if any. - * @param gestureFilter decides whether a drag gesture that started at the given start position - * should be filtered. If the lambda returns `true`, the drag gesture will be ignored. If it - * returns `false`, the drag gesture will be handled. * @param transitionInterceptionThreshold used during a scene transition. For the scene to be * intercepted, the progress value must be above the threshold, and below (1 - threshold). * @param builder the configuration of the different scenes and overlays of this layout. @@ -60,7 +57,6 @@ fun SceneTransitionLayout( modifier: Modifier = Modifier, swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector, swipeDetector: SwipeDetector = DefaultSwipeDetector, - gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter, @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0.05f, builder: SceneTransitionLayoutScope.() -> Unit, ) { @@ -69,7 +65,6 @@ fun SceneTransitionLayout( modifier, swipeSourceDetector, swipeDetector, - gestureFilter, transitionInterceptionThreshold, onLayoutImpl = null, builder, @@ -621,7 +616,6 @@ internal fun SceneTransitionLayoutForTesting( modifier: Modifier = Modifier, swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector, swipeDetector: SwipeDetector = DefaultSwipeDetector, - gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter, transitionInterceptionThreshold: Float = 0f, onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null, builder: SceneTransitionLayoutScope.() -> Unit, @@ -638,7 +632,6 @@ internal fun SceneTransitionLayoutForTesting( transitionInterceptionThreshold = transitionInterceptionThreshold, builder = builder, animationScope = animationScope, - gestureFilter = gestureFilter, ) .also { onLayoutImpl?.invoke(it) } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt index 9e7be37523f4..65c404387734 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.ApproachLayoutModifierNode import androidx.compose.ui.layout.ApproachMeasureScope import androidx.compose.ui.layout.LookaheadScope @@ -71,7 +70,6 @@ internal class SceneTransitionLayoutImpl( * animations. */ internal val animationScope: CoroutineScope, - internal val gestureFilter: (startedPosition: Offset) -> Boolean, ) { /** * The map of [Scene]s. diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt index f758102fee47..54ee78366875 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt @@ -17,7 +17,6 @@ package com.android.compose.animation.scene import androidx.compose.runtime.Stable -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange /** {@link SwipeDetector} helps determine whether a swipe gestured has occurred. */ @@ -32,8 +31,6 @@ interface SwipeDetector { val DefaultSwipeDetector = PassthroughSwipeDetector() -val DefaultGestureFilter = { _: Offset -> false } - /** An {@link SwipeDetector} implementation that recognizes a swipe on any input. */ class PassthroughSwipeDetector : SwipeDetector { override fun detectSwipe(change: PointerInputChange): Boolean { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt index 98d4aaa91458..d201be9acc12 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt @@ -41,7 +41,28 @@ internal fun Modifier.swipeToScene( draggableHandler: DraggableHandlerImpl, swipeDetector: SwipeDetector, ): Modifier { - return this.then(SwipeToSceneElement(draggableHandler, swipeDetector)) + return if (draggableHandler.enabled()) { + this.then(SwipeToSceneElement(draggableHandler, swipeDetector)) + } else { + this + } +} + +private fun DraggableHandlerImpl.enabled(): Boolean { + return isDrivingTransition || contentForSwipes().shouldEnableSwipes(orientation) +} + +private fun DraggableHandlerImpl.contentForSwipes(): Content { + return layoutImpl.contentForUserActions() +} + +/** Whether swipe should be enabled in the given [orientation]. */ +private fun Content.shouldEnableSwipes(orientation: Orientation): Boolean { + if (userActions.isEmpty()) { + return false + } + + return userActions.keys.any { it is Swipe.Resolved && it.direction.orientation == orientation } } private data class SwipeToSceneElement( @@ -64,7 +85,6 @@ private class SwipeToSceneNode( delegate( MultiPointerDraggableNode( orientation = draggableHandler.orientation, - enabled = ::enabled, startDragImmediately = ::startDragImmediately, onDragStarted = draggableHandler::onDragStarted, onFirstPointerDown = ::onFirstPointerDown, @@ -124,22 +144,6 @@ private class SwipeToSceneNode( override fun onCancelPointerInput() = multiPointerDraggableNode.onCancelPointerInput() - private fun enabled(): Boolean { - return draggableHandler.isDrivingTransition || - contentForSwipes().shouldEnableSwipes(multiPointerDraggableNode.orientation) - } - - private fun contentForSwipes(): Content { - return draggableHandler.layoutImpl.contentForUserActions() - } - - /** Whether swipe should be enabled in the given [orientation]. */ - private fun Content.shouldEnableSwipes(orientation: Orientation): Boolean { - return userActions.keys.any { - it is Swipe.Resolved && it.direction.orientation == orientation - } - } - private fun startDragImmediately(startedPosition: Offset): Boolean { // Immediately start the drag if the user can't swipe in the other direction and the gesture // handler can intercept it. @@ -152,7 +156,7 @@ private class SwipeToSceneNode( Orientation.Vertical -> Orientation.Horizontal Orientation.Horizontal -> Orientation.Vertical } - return contentForSwipes().shouldEnableSwipes(oppositeOrientation) + return draggableHandler.contentForSwipes().shouldEnableSwipes(oppositeOrientation) } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt index 16dc0d544fd2..dd4f99f5c64e 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt @@ -109,8 +109,6 @@ class DraggableHandlerTest { val transitionInterceptionThreshold = 0.05f - var gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter - private val layoutImpl = SceneTransitionLayoutImpl( state = layoutState, @@ -123,7 +121,6 @@ class DraggableHandlerTest { // Use testScope and not backgroundScope here because backgroundScope does not // work well with advanceUntilIdle(), which is used by some tests. animationScope = testScope, - gestureFilter = { startedPosition -> gestureFilter.invoke(startedPosition) }, ) .apply { setContentsAndLayoutTargetSizeForTest(LAYOUT_SIZE) } @@ -352,13 +349,6 @@ class DraggableHandlerTest { } @Test - fun onDragStarted_doesNotStartTransition_whenGestureFiltered() = runGestureTest { - gestureFilter = { _ -> true } - onDragStarted(overSlop = down(fractionOfScreen = 0.1f), expectedConsumedOverSlop = 0f) - assertIdle(currentScene = SceneA) - } - - @Test fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest { val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f)) assertTransition(currentScene = SceneA) diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt index 493f3a1377cc..c8f6e6d99933 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Velocity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.compose.modifiers.thenIf import com.android.compose.nestedscroll.SuspendedValue import com.google.common.truth.Truth.assertThat import kotlin.properties.Delegates @@ -94,19 +95,20 @@ class MultiPointerDraggableTest { Box( Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() }) .nestedScrollDispatcher() - .multiPointerDraggable( - orientation = Orientation.Vertical, - enabled = { enabled }, - startDragImmediately = { false }, - onDragStarted = { _, _, _ -> - started = true - SimpleDragController( - onDrag = { dragged = true }, - onStop = { stopped = true }, - ) - }, - dispatcher = defaultDispatcher, - ) + .thenIf(enabled) { + Modifier.multiPointerDraggable( + orientation = Orientation.Vertical, + startDragImmediately = { false }, + onDragStarted = { _, _, _ -> + started = true + SimpleDragController( + onDrag = { dragged = true }, + onStop = { stopped = true }, + ) + }, + dispatcher = defaultDispatcher, + ) + } ) } @@ -164,7 +166,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, // We want to start a drag gesture immediately startDragImmediately = { true }, onDragStarted = { _, _, _ -> @@ -238,7 +239,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true @@ -358,7 +358,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> started = true @@ -464,7 +463,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> verticalStarted = true @@ -477,7 +475,6 @@ class MultiPointerDraggableTest { ) .multiPointerDraggable( orientation = Orientation.Horizontal, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> horizontalStarted = true @@ -570,7 +567,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, swipeDetector = object : SwipeDetector { @@ -672,7 +668,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> SimpleDragController( @@ -744,7 +739,6 @@ class MultiPointerDraggableTest { .nestedScrollDispatcher() .multiPointerDraggable( orientation = Orientation.Vertical, - enabled = { true }, startDragImmediately = { false }, onDragStarted = { _, _, _ -> SimpleDragController( diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt index 25e87132eb0e..ce64628a863c 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt @@ -22,11 +22,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -36,8 +40,11 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertPositionInRootIsEqualTo +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeWithVelocity import androidx.compose.ui.unit.Density @@ -844,4 +851,29 @@ class SwipeToSceneTest { assertThat(transition.progress).isEqualTo(1f) assertThat(availableOnPostScroll).isEqualTo(ovescrollPx) } + + @Test + fun sceneWithoutSwipesDoesNotConsumeGestures() { + val buttonTag = "button" + + rule.setContent { + Box { + var count by remember { mutableStateOf(0) } + Button(onClick = { count++ }, Modifier.testTag(buttonTag).align(Alignment.Center)) { + Text("Count: $count") + } + + SceneTransitionLayout(remember { MutableSceneTransitionLayoutState(SceneA) }) { + scene(SceneA) { Box(Modifier.fillMaxSize()) } + } + } + } + + rule.onNodeWithTag(buttonTag).assertTextEquals("Count: 0") + + // Click on the root at its center, where the button is located. Clicks should go through + // the STL and reach the button given that there is no swipes for the current scene. + repeat(3) { rule.onRoot().performClick() } + rule.onNodeWithTag(buttonTag).assertTextEquals("Count: 3") + } } diff --git a/packages/SystemUI/customization/Android.bp b/packages/SystemUI/customization/Android.bp index c399abc7d81d..81d92faa40af 100644 --- a/packages/SystemUI/customization/Android.bp +++ b/packages/SystemUI/customization/Android.bp @@ -36,6 +36,7 @@ android_library { "SystemUIPluginLib", "SystemUIUnfoldLib", "kotlinx_coroutines", + "monet", "dagger2", "jsr330", ], diff --git a/packages/SystemUI/customization/res/values/ids.xml b/packages/SystemUI/customization/res/values/ids.xml index 5eafbfc1f0b1..ec466f041179 100644 --- a/packages/SystemUI/customization/res/values/ids.xml +++ b/packages/SystemUI/customization/res/values/ids.xml @@ -6,4 +6,13 @@ <item type="id" name="weather_clock_weather_icon" /> <item type="id" name="weather_clock_temperature" /> <item type="id" name="weather_clock_alarm_dnd" /> + + <item type="id" name="HOUR_DIGIT_PAIR"/> + <item type="id" name="MINUTE_DIGIT_PAIR"/> + <item type="id" name="HOUR_FIRST_DIGIT"/> + <item type="id" name="HOUR_SECOND_DIGIT"/> + <item type="id" name="MINUTE_FIRST_DIGIT"/> + <item type="id" name="MINUTE_SECOND_DIGIT"/> + <item type="id" name="TIME_FULL_FORMAT"/> + <item type="id" name="DATE_FORMAT"/> </resources>
\ No newline at end of file diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt index 1863cd861c05..9877406eeac2 100644 --- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt @@ -62,6 +62,7 @@ constructor( // implement the get method and ensure a value is returned before initialization is complete. private var logger = DEFAULT_LOGGER get() = field ?: DEFAULT_LOGGER + var messageBuffer: MessageBuffer get() = logger.buffer set(value) { @@ -123,24 +124,24 @@ constructor( attrs, R.styleable.AnimatableClockView, defStyleAttr, - defStyleRes + defStyleRes, ) try { dozingWeightInternal = animatableClockViewAttributes.getInt( R.styleable.AnimatableClockView_dozeWeight, - /* default = */ 100 + /* default = */ 100, ) lockScreenWeightInternal = animatableClockViewAttributes.getInt( R.styleable.AnimatableClockView_lockScreenWeight, - /* default = */ 300 + /* default = */ 300, ) chargeAnimationDelay = animatableClockViewAttributes.getInt( R.styleable.AnimatableClockView_chargeAnimationDelay, - /* default = */ 200 + /* default = */ 200, ) } finally { animatableClockViewAttributes.recycle() @@ -151,14 +152,14 @@ constructor( attrs, android.R.styleable.TextView, defStyleAttr, - defStyleRes + defStyleRes, ) try { isSingleLineInternal = textViewAttributes.getBoolean( android.R.styleable.TextView_singleLine, - /* default = */ false + /* default = */ false, ) } finally { textViewAttributes.recycle() @@ -280,7 +281,7 @@ constructor( text: CharSequence, start: Int, lengthBefore: Int, - lengthAfter: Int + lengthAfter: Int, ) { logger.d({ "onTextChanged($str1)" }) { str1 = text.toString() } super.onTextChanged(text, start, lengthBefore, lengthAfter) @@ -305,7 +306,7 @@ constructor( interpolator = null, duration = 0, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) setTextStyle( weight = lockScreenWeight, @@ -314,7 +315,7 @@ constructor( interpolator = null, duration = COLOR_ANIM_DURATION, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) } @@ -327,7 +328,7 @@ constructor( interpolator = null, duration = 0, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) setTextStyle( weight = lockScreenWeight, @@ -336,7 +337,7 @@ constructor( duration = APPEAR_ANIM_DURATION, interpolator = Interpolators.EMPHASIZED_DECELERATE, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) } @@ -353,7 +354,7 @@ constructor( interpolator = null, duration = 0, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) setTextStyle( weight = dozingWeightInternal, @@ -362,7 +363,7 @@ constructor( interpolator = Interpolators.EMPHASIZED_DECELERATE, duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(), delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) } @@ -381,7 +382,7 @@ constructor( interpolator = null, duration = CHARGE_ANIM_DURATION_PHASE_1, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) } setTextStyle( @@ -391,7 +392,7 @@ constructor( interpolator = null, duration = CHARGE_ANIM_DURATION_PHASE_0, delay = chargeAnimationDelay.toLong(), - onAnimationEnd = startAnimPhase2 + onAnimationEnd = startAnimPhase2, ) } @@ -404,7 +405,7 @@ constructor( interpolator = null, duration = DOZE_ANIM_DURATION, delay = 0, - onAnimationEnd = null + onAnimationEnd = null, ) } @@ -444,7 +445,7 @@ constructor( interpolator: TimeInterpolator?, duration: Long, delay: Long, - onAnimationEnd: Runnable? + onAnimationEnd: Runnable?, ) { textAnimator?.let { it.setTextStyle( @@ -454,7 +455,7 @@ constructor( duration = duration, interpolator = interpolator, delay = delay, - onAnimationEnd = onAnimationEnd + onAnimationEnd = onAnimationEnd, ) it.glyphFilter = glyphFilter } @@ -468,7 +469,7 @@ constructor( duration = duration, interpolator = interpolator, delay = delay, - onAnimationEnd = onAnimationEnd + onAnimationEnd = onAnimationEnd, ) textAnimator.glyphFilter = glyphFilter } @@ -476,6 +477,7 @@ constructor( } fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context)) + fun refreshFormat(use24HourFormat: Boolean) { Patterns.update(context) @@ -560,18 +562,11 @@ constructor( * @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means * it finished moving. */ - fun offsetGlyphsForStepClockAnimation( - distance: Float, - fraction: Float, - ) { + fun offsetGlyphsForStepClockAnimation(distance: Float, fraction: Float) { for (i in 0 until NUM_DIGITS) { val dir = if (isLayoutRtl) -1 else 1 val digitFraction = - getDigitFraction( - digit = i, - isMovingToCenter = distance > 0, - fraction = fraction, - ) + getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction) val moveAmountForDigit = dir * distance * digitFraction glyphOffsets[i] = moveAmountForDigit diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt new file mode 100644 index 000000000000..d001ef966c13 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt @@ -0,0 +1,448 @@ +/* + * 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.shared.clocks + +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Resources +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.TypedValue +import com.android.internal.graphics.ColorUtils +import com.android.internal.graphics.cam.Cam +import com.android.internal.graphics.cam.CamUtils +import com.android.internal.policy.SystemBarUtils +import com.android.systemui.log.core.Logger +import com.android.systemui.log.core.MessageBuffer +import com.android.systemui.monet.ColorScheme +import com.android.systemui.monet.Style as MonetStyle +import com.android.systemui.monet.TonalPalette +import java.io.IOException +import kotlin.math.abs + +class AssetLoader +private constructor( + private val pluginCtx: Context, + private val sysuiCtx: Context, + private val baseDir: String, + var colorScheme: ColorScheme?, + var seedColor: Int?, + var overrideChroma: Float?, + val typefaceCache: TypefaceCache, + val getThemeSeedColor: (Context) -> Int, + messageBuffer: MessageBuffer, +) { + val logger = Logger(messageBuffer, TAG) + private val resources = + listOf( + Pair(pluginCtx.resources, pluginCtx.packageName), + Pair(sysuiCtx.resources, sysuiCtx.packageName), + ) + + constructor( + pluginCtx: Context, + sysuiCtx: Context, + baseDir: String, + messageBuffer: MessageBuffer, + getThemeSeedColor: ((Context) -> Int)? = null, + ) : this( + pluginCtx, + sysuiCtx, + baseDir, + colorScheme = null, + seedColor = null, + overrideChroma = null, + typefaceCache = + TypefaceCache(messageBuffer) { Typeface.createFromAsset(pluginCtx.assets, it) }, + getThemeSeedColor = getThemeSeedColor ?: Companion::getThemeSeedColor, + messageBuffer = messageBuffer, + ) + + fun listAssets(path: String): List<String> { + return pluginCtx.resources.assets.list("$baseDir$path")?.toList() ?: emptyList() + } + + fun tryReadString(resStr: String): String? = tryRead(resStr, ::readString) + + fun readString(resStr: String): String { + val resPair = resolveResourceId(resStr) + if (resPair == null) { + throw IOException("Failed to parse string: $resStr") + } + + val (res, id) = resPair + return res.getString(id) + } + + fun tryReadColor(resStr: String): Int? = tryRead(resStr, ::readColor) + + fun readColor(resStr: String): Int { + if (resStr.startsWith("#")) { + return Color.parseColor(resStr) + } + + val schemeColor = tryParseColorFromScheme(resStr) + if (schemeColor != null) { + logColor("ColorScheme: $resStr", schemeColor) + return checkChroma(schemeColor) + } + + val result = resolveColorResourceId(resStr) + if (result == null) { + throw IOException("Failed to parse color: $resStr") + } + + val (res, colorId, targetTone) = result + val color = res.getColor(colorId) + if (targetTone == null || TonalPalette.SHADE_KEYS.contains(targetTone.toInt())) { + logColor("Resources: $resStr", color) + return checkChroma(color) + } else { + val interpolatedColor = + ColorStateList.valueOf(color) + .withLStar((1000f - targetTone) / 10f) + .getDefaultColor() + logColor("Resources (interpolated tone): $resStr", interpolatedColor) + return checkChroma(interpolatedColor) + } + } + + private fun checkChroma(color: Int): Int { + return overrideChroma?.let { + val cam = Cam.fromInt(color) + val tone = CamUtils.lstarFromInt(color) + val result = ColorUtils.CAMToColor(cam.hue, it, tone) + logColor("Chroma override", result) + result + } ?: color + } + + private fun tryParseColorFromScheme(resStr: String): Int? { + val colorScheme = this.colorScheme + if (colorScheme == null) { + logger.w("No color scheme available") + return null + } + + val (packageName, category, name) = parseResourceId(resStr) + if (packageName != "android" || category != "color") { + logger.w("Failed to parse package from $resStr") + return null + } + + var parts = name.split('_') + if (parts.size != 3) { + logger.w("Failed to find palette and shade from $name") + return null + } + val (_, paletteKey, shadeKeyStr) = parts + + val palette = + when (paletteKey) { + "accent1" -> colorScheme.accent1 + "accent2" -> colorScheme.accent2 + "accent3" -> colorScheme.accent3 + "neutral1" -> colorScheme.neutral1 + "neutral2" -> colorScheme.neutral2 + else -> return null + } + + if (shadeKeyStr.contains("+") || shadeKeyStr.contains("-")) { + val signIndex = shadeKeyStr.indexOfLast { it == '-' || it == '+' } + // Use the tone of the seed color if it was set explicitly. + var baseTone = + if (seedColor != null) colorScheme.seedTone.toFloat() + else shadeKeyStr.substring(0, signIndex).toFloatOrNull() + val diff = shadeKeyStr.substring(signIndex).toFloatOrNull() + + if (baseTone == null) { + logger.w("Failed to parse base tone from $shadeKeyStr") + return null + } + + if (diff == null) { + logger.w("Failed to parse relative tone from $shadeKeyStr") + return null + } + return palette.getAtTone(baseTone + diff) + } else { + val shadeKey = shadeKeyStr.toIntOrNull() + if (shadeKey == null) { + logger.w("Failed to parse tone from $shadeKeyStr") + return null + } + return palette.allShadesMapped.get(shadeKey) ?: palette.getAtTone(shadeKey.toFloat()) + } + } + + fun readFontAsset(resStr: String): Typeface = typefaceCache.getTypeface(resStr) + + fun tryReadTextAsset(path: String?): String? = tryRead(path, ::readTextAsset) + + fun readTextAsset(path: String): String { + return pluginCtx.resources.assets.open("$baseDir$path").use { stream -> + val buffer = ByteArray(stream.available()) + stream.read(buffer) + String(buffer) + } + } + + fun tryReadDrawableAsset(path: String?): Drawable? = tryRead(path, ::readDrawableAsset) + + fun readDrawableAsset(path: String): Drawable { + var result: Drawable? + + if (path.startsWith("@")) { + val pair = resolveResourceId(path) + if (pair == null) { + throw IOException("Failed to parse $path to an id") + } + val (res, id) = pair + result = res.getDrawable(id) + } else if (path.endsWith("xml")) { + // TODO(b/248609434): Support xml files in assets + throw IOException("Cannot load xml files from assets") + } else { + // Attempt to load as if it's a bitmap and directly loadable + result = + pluginCtx.resources.assets.open("$baseDir$path").use { stream -> + Drawable.createFromResourceStream( + pluginCtx.resources, + TypedValue(), + stream, + null, + ) + } + } + + return result ?: throw IOException("Failed to load: $baseDir$path") + } + + fun parseResourceId(resStr: String): Triple<String?, String, String> { + if (!resStr.startsWith("@")) { + throw IOException("Invalid resource id: $resStr; Must start with '@'") + } + + // Parse out resource string + val parts = resStr.drop(1).split('/', ':') + return when (parts.size) { + 2 -> Triple(null, parts[0], parts[1]) + 3 -> Triple(parts[0], parts[1], parts[2]) + else -> throw IOException("Failed to parse resource string: $resStr") + } + } + + fun resolveColorResourceId(resStr: String): Triple<Resources, Int, Float?>? { + var (packageName, category, name) = parseResourceId(resStr) + + // Convert relative tonal specifiers to standard + val relIndex = name.indexOfLast { it == '_' } + val isToneRelative = name.contains("-") || name.contains("+") + val targetTone = + if (packageName != "android") { + null + } else if (isToneRelative) { + val signIndex = name.indexOfLast { it == '-' || it == '+' } + val baseTone = name.substring(relIndex + 1, signIndex).toFloatOrNull() + var diff = name.substring(signIndex).toFloatOrNull() + if (baseTone == null || diff == null) { + logger.w("Failed to parse relative tone from $name") + return null + } + baseTone + diff + } else { + val absTone = name.substring(relIndex + 1).toFloatOrNull() + if (absTone == null) { + logger.w("Failed to parse absolute tone from $name") + return null + } + absTone + } + + if ( + targetTone != null && + (isToneRelative || !TonalPalette.SHADE_KEYS.contains(targetTone.toInt())) + ) { + val closeTone = TonalPalette.SHADE_KEYS.minBy { abs(it - targetTone) } + val prevName = name + name = name.substring(0, relIndex + 1) + closeTone + logger.i("Converted $prevName to $name") + } + + val result = resolveResourceId(packageName, category, name) + if (result == null) { + return null + } + + val (res, resId) = result + return Triple(res, resId, targetTone) + } + + fun resolveResourceId(resStr: String): Pair<Resources, Int>? { + val (packageName, category, name) = parseResourceId(resStr) + return resolveResourceId(packageName, category, name) + } + + fun resolveResourceId( + packageName: String?, + category: String, + name: String, + ): Pair<Resources, Int>? { + for ((res, ctxPkgName) in resources) { + val result = res.getIdentifier(name, category, packageName ?: ctxPkgName) + if (result != 0) { + return Pair(res, result) + } + } + return null + } + + private fun <TArg : Any, TRes : Any> tryRead(arg: TArg?, fn: (TArg) -> TRes): TRes? { + try { + if (arg == null) { + return null + } + return fn(arg) + } catch (ex: IOException) { + logger.w("Failed to read $arg", ex) + return null + } + } + + fun assetExists(path: String): Boolean { + try { + if (path.startsWith("@")) { + val pair = resolveResourceId(path) + val colorPair = resolveColorResourceId(path) + return pair != null || colorPair != null + } else { + val stream = pluginCtx.resources.assets.open("$baseDir$path") + if (stream == null) { + return false + } + + stream.close() + return true + } + } catch (ex: IOException) { + return false + } + } + + fun copy(messageBuffer: MessageBuffer? = null): AssetLoader = + AssetLoader( + pluginCtx, + sysuiCtx, + baseDir, + colorScheme, + seedColor, + overrideChroma, + typefaceCache, + getThemeSeedColor, + messageBuffer ?: logger.buffer, + ) + + fun setSeedColor(seedColor: Int?, style: MonetStyle?) { + this.seedColor = seedColor + refreshColorPalette(style) + } + + fun refreshColorPalette(style: MonetStyle?) { + val seedColor = + this.seedColor ?: getThemeSeedColor(sysuiCtx).also { logColor("Theme Seed Color", it) } + this.colorScheme = + ColorScheme( + seedColor, + false, // darkTheme is not used for palette generation + style ?: MonetStyle.CLOCK, + ) + + // Enforce low chroma on output colors if low chroma theme is selected + this.overrideChroma = run { + val cam = colorScheme?.seed?.let { Cam.fromInt(it) } + if (cam != null && cam.chroma < LOW_CHROMA_LIMIT) { + return@run cam.chroma * LOW_CHROMA_SCALE + } + return@run null + } + } + + fun getClockPaddingStart(): Int { + val result = resolveResourceId(null, "dimen", "clock_padding_start") + if (result != null) { + val (res, id) = result + return res.getDimensionPixelSize(id) + } + return -1 + } + + fun getStatusBarHeight(): Int { + val display = pluginCtx.getDisplayNoVerify() + if (display != null) { + return SystemBarUtils.getStatusBarHeight(pluginCtx.resources, display.cutout) + } + + logger.w("No display available; falling back to android.R.dimen.status_bar_height") + val statusBarHeight = resolveResourceId("android", "dimen", "status_bar_height") + if (statusBarHeight != null) { + val (res, resId) = statusBarHeight + return res.getDimensionPixelSize(resId) + } + + throw Exception("Could not fetch StatusBarHeight") + } + + fun getResourcesId(name: String): Int = getResource("id", name) { _, id -> id } + + fun getDimen(name: String): Int = getResource("dimen", name, Resources::getDimensionPixelSize) + + fun getString(name: String): String = getResource("string", name, Resources::getString) + + private fun <T> getResource( + category: String, + name: String, + getter: (res: Resources, id: Int) -> T, + ): T { + val result = resolveResourceId(null, category, name) + if (result != null) { + val (res, id) = result + if (id == -1) throw Exception("Cannot find id of $id from $TAG") + return getter(res, id) + } + throw Exception("Cannot find id of $name from $TAG") + } + + private fun logColor(name: String, color: Int) { + if (DEBUG_COLOR) { + val cam = Cam.fromInt(color) + val tone = CamUtils.lstarFromInt(color) + logger.i("$name -> (hue: ${cam.hue}, chroma: ${cam.chroma}, tone: $tone)") + } + } + + companion object { + private val DEBUG_COLOR = true + private val LOW_CHROMA_LIMIT = 15 + private val LOW_CHROMA_SCALE = 1.5f + private val TAG = AssetLoader::class.simpleName!! + + private fun getThemeSeedColor(ctx: Context): Int { + return ctx.resources.getColor(android.R.color.system_palette_key_color_primary_light) + } + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt new file mode 100644 index 000000000000..5a041691b06a --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt @@ -0,0 +1,21 @@ +/* + * 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.shared.clocks + +object ClockAnimation { + const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30 +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt new file mode 100644 index 000000000000..f5e843234095 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt @@ -0,0 +1,288 @@ +/* + * 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.shared.clocks + +import android.graphics.Point +import android.view.animation.Interpolator +import com.android.app.animation.Interpolators +import com.android.internal.annotations.Keep +import com.android.systemui.monet.Style as MonetStyle +import com.android.systemui.shared.clocks.view.HorizontalAlignment +import com.android.systemui.shared.clocks.view.VerticalAlignment + +/** Data format for a simple asset-defined clock */ +@Keep +data class ClockDesign( + val id: String, + val name: String? = null, + val description: String? = null, + val thumbnail: String? = null, + val large: ClockFace? = null, + val small: ClockFace? = null, + val colorPalette: MonetStyle? = null, +) + +/** Describes a clock using layers */ +@Keep +data class ClockFace( + val layers: List<ClockLayer> = listOf<ClockLayer>(), + val layerBounds: LayerBounds = LayerBounds.FIT, + val wallpaper: String? = null, + val faceLayout: DigitalFaceLayout? = null, + val pickerScale: ClockFaceScaleInPicker? = ClockFaceScaleInPicker(1.0f, 1.0f), +) + +@Keep data class ClockFaceScaleInPicker(val scaleX: Float, val scaleY: Float) + +/** Base Type for a Clock Layer */ +@Keep +interface ClockLayer { + /** Override of face LayerBounds setting for this layer */ + val layerBounds: LayerBounds? +} + +/** Clock layer that renders a static asset */ +@Keep +data class AssetLayer( + /** Asset to render in this layer */ + val asset: AssetReference, + override val layerBounds: LayerBounds? = null, +) : ClockLayer + +/** Clock layer that renders the time (or a component of it) using numerals */ +@Keep +data class DigitalHandLayer( + /** See SimpleDateFormat for timespec format info */ + val timespec: DigitalTimespec, + val style: TextStyle, + // adoStyle concrete type must match style, + // cause styles will transition between style and aodStyle + val aodStyle: TextStyle?, + val timer: Int? = null, + override val layerBounds: LayerBounds? = null, + var faceLayout: DigitalFaceLayout? = null, + // we pass 12-hour format from json, which will be converted to 24-hour format in codes + val dateTimeFormat: String, + val alignment: DigitalAlignment?, + // ratio of margins to measured size, currently used for handwritten clocks + val marginRatio: DigitalMarginRatio? = DigitalMarginRatio(), +) : ClockLayer + +/** Clock layer that renders the time (or a component of it) using numerals */ +@Keep +data class ComposedDigitalHandLayer( + val customizedView: String? = null, + /** See SimpleDateFormat for timespec format info */ + val digitalLayers: List<DigitalHandLayer> = listOf<DigitalHandLayer>(), + override val layerBounds: LayerBounds? = null, +) : ClockLayer + +@Keep +data class DigitalAlignment( + val horizontalAlignment: HorizontalAlignment?, + val verticalAlignment: VerticalAlignment?, +) + +@Keep +data class DigitalMarginRatio( + val left: Float = 0F, + val top: Float = 0F, + val right: Float = 0F, + val bottom: Float = 0F, +) + +/** Clock layer which renders a component of the time using an analog hand */ +@Keep +data class AnalogHandLayer( + val timespec: AnalogTimespec, + val tickMode: AnalogTickMode, + val asset: AssetReference, + val timer: Int? = null, + val clock_pivot: Point = Point(0, 0), + val asset_pivot: Point? = null, + val length: Float = 1f, + override val layerBounds: LayerBounds? = null, +) : ClockLayer + +/** Clock layer which renders the time using an AVD */ +@Keep +data class AnimatedHandLayer( + val timespec: AnalogTimespec, + val asset: AssetReference, + val timer: Int? = null, + override val layerBounds: LayerBounds? = null, +) : ClockLayer + +/** A collection of asset references for use in different device modes */ +@Keep +data class AssetReference( + val light: String, + val dark: String, + val doze: String? = null, + val lightTint: String? = null, + val darkTint: String? = null, + val dozeTint: String? = null, +) + +/** + * Core TextStyling attributes for text clocks. Both color and sizing information can be applied to + * either subtype. + */ +@Keep +interface TextStyle { + // fontSizeScale is a scale factor applied to the default clock's font size. + val fontSizeScale: Float? +} + +/** + * This specifies a font and styling parameters for that font. This is rendered using a text view + * and the text animation classes used by the default clock. To ensure default value take effects, + * all parameters MUST have a default value + */ +@Keep +data class FontTextStyle( + // Font to load and use in the TextView + val fontFamily: String? = null, + val lineHeight: Float? = null, + val borderWidth: String? = null, + // ratio of borderWidth / fontSize + val borderWidthScale: Float? = null, + // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100` + val fillColorLight: String? = null, + // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100` + val fillColorDark: String? = null, + override val fontSizeScale: Float? = null, + /** + * use `wdth` for width, `wght` for weight, 'opsz' for optical size single quote for tag name, + * and no quote for value separate different axis with `,` e.g. "'wght' 1000, 'wdth' 108, 'opsz' + * 90" + */ + var fontVariation: String? = null, + // used when alternate in one font file is needed + var fontFeatureSettings: String? = null, + val renderType: RenderType = RenderType.STROKE_TEXT, + val outlineColor: String? = null, + val transitionDuration: Long = -1L, + val transitionInterpolator: InterpolatorEnum? = null, +) : TextStyle + +/** + * As an alternative to using a font, we can instead render a digital clock using a set of drawables + * for each numeral, and optionally a colon. These drawables will be rendered directly after sizing + * and placing them. This may be easier than generating a font file in some cases, and is provided + * for ease of use. Unlike fonts, these are not localizable to other numeric systems (like Burmese). + */ +@Keep +data class LottieTextStyle( + val numbers: List<String> = listOf(), + // Spacing between numbers, dimension string + val spacing: String = "0dp", + // Colon drawable may be omitted if unused in format spec + val colon: String? = null, + // key is keypath name to get strokes from lottie, value is the color name to query color in + // palette, e.g. @android:color/system_accent1_100 + val fillColorLightMap: Map<String, String>? = null, + val fillColorDarkMap: Map<String, String>? = null, + override val fontSizeScale: Float? = null, + val paddingVertical: String = "0dp", + val paddingHorizontal: String = "0dp", +) : TextStyle + +/** Layer sizing mode for the clockface or layer */ +enum class LayerBounds { + /** + * Sized so the larger dimension matches the allocated space. This results in some of the + * allocated space being unused. + */ + FIT, + + /** + * Sized so the smaller dimension matches the allocated space. This will clip some content to + * the edges of the space. + */ + FILL, + + /** Fills the allocated space exactly by stretching the layer */ + STRETCH, +} + +/** Ticking mode for analog hands. */ +enum class AnalogTickMode { + SWEEP, + TICK, +} + +/** Timspec options for Analog Hands. Named for tick interval. */ +enum class AnalogTimespec { + SECONDS, + MINUTES, + HOURS, + HOURS_OF_DAY, + DAY_OF_WEEK, + DAY_OF_MONTH, + DAY_OF_YEAR, + WEEK, + MONTH, + TIMER, +} + +enum class DigitalTimespec { + TIME_FULL_FORMAT, + DIGIT_PAIR, + FIRST_DIGIT, + SECOND_DIGIT, + DATE_FORMAT, +} + +enum class DigitalFaceLayout { + // can only use HH_PAIR, MM_PAIR from DigitalTimespec + TWO_PAIRS_VERTICAL, + TWO_PAIRS_HORIZONTAL, + // can only use HOUR_FIRST_DIGIT, HOUR_SECOND_DIGIT, MINUTE_FIRST_DIGIT, MINUTE_SECOND_DIGIT + // from DigitalTimespec, used for tabular layout when the font doesn't support tnum + FOUR_DIGITS_ALIGN_CENTER, + FOUR_DIGITS_HORIZONTAL, +} + +enum class RenderType { + CHANGE_WEIGHT, + HOLLOW_TEXT, + STROKE_TEXT, + OUTER_OUTLINE_TEXT, +} + +enum class InterpolatorEnum(factory: () -> Interpolator) { + STANDARD({ Interpolators.STANDARD }), + EMPHASIZED({ Interpolators.EMPHASIZED }); + + val interpolator: Interpolator by lazy(factory) +} + +fun generateDigitalLayerIdString(layer: DigitalHandLayer): String { + return if ( + layer.timespec == DigitalTimespec.TIME_FULL_FORMAT || + layer.timespec == DigitalTimespec.DATE_FORMAT + ) { + layer.timespec.toString() + } else { + if ("h" in layer.dateTimeFormat) { + "HOUR" + "_" + layer.timespec.toString() + } else { + "MINUTE" + "_" + layer.timespec.toString() + } + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt index 954155d16b05..9da3022fc0d8 100644 --- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt @@ -65,7 +65,7 @@ private val KNOWN_PLUGINS = private fun <TKey : Any, TVal : Any> ConcurrentHashMap<TKey, TVal>.concurrentGetOrPut( key: TKey, value: TVal, - onNew: (TVal) -> Unit + onNew: (TVal) -> Unit, ): TVal { val result = this.putIfAbsent(key, value) if (result == null) { @@ -110,7 +110,7 @@ open class ClockRegistry( selfChange: Boolean, uris: Collection<Uri>, flags: Int, - userId: Int + userId: Int, ) { scope.launch(bgDispatcher) { querySettings() } } @@ -180,7 +180,7 @@ open class ClockRegistry( override fun onPluginLoaded( plugin: ClockProviderPlugin, pluginContext: Context, - manager: PluginLifecycleManager<ClockProviderPlugin> + manager: PluginLifecycleManager<ClockProviderPlugin>, ) { plugin.initialize(clockBuffers) @@ -218,7 +218,7 @@ open class ClockRegistry( override fun onPluginUnloaded( plugin: ClockProviderPlugin, - manager: PluginLifecycleManager<ClockProviderPlugin> + manager: PluginLifecycleManager<ClockProviderPlugin>, ) { for (clock in plugin.getClocks()) { val id = clock.clockId @@ -290,12 +290,12 @@ open class ClockRegistry( Settings.Secure.getStringForUser( context.contentResolver, Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE, - ActivityManager.getCurrentUser() + ActivityManager.getCurrentUser(), ) } else { Settings.Secure.getString( context.contentResolver, - Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE + Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE, ) } @@ -320,13 +320,13 @@ open class ClockRegistry( context.contentResolver, Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE, json, - ActivityManager.getCurrentUser() + ActivityManager.getCurrentUser(), ) } else { Settings.Secure.putString( context.contentResolver, Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE, - json + json, ) } } catch (ex: Exception) { @@ -418,7 +418,7 @@ open class ClockRegistry( pluginManager.addPluginListener( pluginListener, ClockProviderPlugin::class.java, - /*allowMultiple=*/ true + /*allowMultiple=*/ true, ) scope.launch(bgDispatcher) { querySettings() } @@ -427,7 +427,7 @@ open class ClockRegistry( Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE), /*notifyForDescendants=*/ false, settingObserver, - UserHandle.USER_ALL + UserHandle.USER_ALL, ) ActivityManager.getService().registerUserSwitchObserver(userSwitchObserver, TAG) @@ -435,7 +435,7 @@ open class ClockRegistry( context.contentResolver.registerContentObserver( Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE), /*notifyForDescendants=*/ false, - settingObserver + settingObserver, ) } } @@ -504,7 +504,7 @@ open class ClockRegistry( val isCurrent = currentClockId == info.metadata.clockId logger.log( if (isCurrent) LogLevel.INFO else LogLevel.DEBUG, - { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" } + { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }, ) { str1 = info.metadata.clockId str2 = info.manager.toString() @@ -516,7 +516,7 @@ open class ClockRegistry( val isCurrent = currentClockId == info.metadata.clockId logger.log( if (isCurrent) LogLevel.INFO else LogLevel.DEBUG, - { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" } + { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }, ) { str1 = info.metadata.clockId str2 = info.manager.toString() @@ -532,7 +532,7 @@ open class ClockRegistry( val isCurrent = currentClockId == info.metadata.clockId logger.log( if (isCurrent) LogLevel.WARNING else LogLevel.DEBUG, - { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" } + { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }, ) { str1 = info.metadata.clockId str2 = info.manager.toString() @@ -548,7 +548,7 @@ open class ClockRegistry( val isCurrent = currentClockId == info.metadata.clockId logger.log( if (isCurrent) LogLevel.INFO else LogLevel.DEBUG, - { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" } + { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }, ) { str1 = info.metadata.clockId str2 = info.manager.toString() diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt index 4802e3447eb2..07191c671a34 100644 --- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt @@ -34,7 +34,7 @@ class DefaultClockProvider( val layoutInflater: LayoutInflater, val resources: Resources, val hasStepClockAnimation: Boolean = false, - val migratedClocks: Boolean = false + val migratedClocks: Boolean = false, ) : ClockProvider { private var messageBuffers: ClockMessageBuffers? = null diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt new file mode 100644 index 000000000000..38697063bea5 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt @@ -0,0 +1,104 @@ +/* + * 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.shared.clocks + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.graphics.Point + +class DigitTranslateAnimator(val updateCallback: () -> Unit) { + val DEFAULT_ANIMATION_DURATION = 500L + val updatedTranslate = Point(0, 0) + + val baseTranslation = Point(0, 0) + var targetTranslation: Point? = null + val bounceAnimator: ValueAnimator = + ValueAnimator.ofFloat(1f).apply { + duration = DEFAULT_ANIMATION_DURATION + addUpdateListener { + updateTranslation(it.animatedFraction, updatedTranslate) + updateCallback() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + rebase() + } + + override fun onAnimationCancel(animation: Animator) { + rebase() + } + } + ) + } + + fun rebase() { + baseTranslation.x = updatedTranslate.x + baseTranslation.y = updatedTranslate.y + } + + fun animatePosition( + animate: Boolean = true, + delay: Long = 0, + duration: Long = -1L, + interpolator: TimeInterpolator? = null, + targetTranslation: Point? = null, + onAnimationEnd: Runnable? = null, + ) { + this.targetTranslation = targetTranslation ?: Point(0, 0) + if (animate) { + bounceAnimator.cancel() + bounceAnimator.startDelay = delay + bounceAnimator.duration = + if (duration == -1L) { + DEFAULT_ANIMATION_DURATION + } else { + duration + } + interpolator?.let { bounceAnimator.interpolator = it } + if (onAnimationEnd != null) { + val listener = + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + onAnimationEnd.run() + bounceAnimator.removeListener(this) + } + + override fun onAnimationCancel(animation: Animator) { + bounceAnimator.removeListener(this) + } + } + bounceAnimator.addListener(listener) + } + bounceAnimator.start() + } else { + // No animation is requested, thus set base and target state to the same state. + updateTranslation(1F, updatedTranslate) + rebase() + updateCallback() + } + } + + fun updateTranslation(progress: Float, outPoint: Point) { + outPoint.x = + (baseTranslation.x + progress * (targetTranslation!!.x - baseTranslation.x)).toInt() + outPoint.y = + (baseTranslation.y + progress * (targetTranslation!!.y - baseTranslation.y)).toInt() + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt new file mode 100644 index 000000000000..2be6c6573ebe --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt @@ -0,0 +1,64 @@ +/* + * 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.shared.clocks + +import android.content.Context +import android.util.TypedValue +import java.util.regex.Pattern + +class DimensionParser(private val ctx: Context) { + fun convert(dimension: String?): Float? { + if (dimension == null) { + return null + } + return convert(dimension) + } + + fun convert(dimension: String): Float { + val metrics = ctx.resources.displayMetrics + val (value, unit) = parse(dimension) + return TypedValue.applyDimension(unit, value, metrics) + } + + fun parse(dimension: String): Pair<Float, Int> { + val matcher = parserPattern.matcher(dimension) + if (!matcher.matches()) { + throw NumberFormatException("Failed to parse '$dimension'") + } + + val value = + matcher.group(1)?.toFloat() ?: throw NumberFormatException("Bad value in '$dimension'") + val unit = + dimensionMap.get(matcher.group(3) ?: "") + ?: throw NumberFormatException("Bad unit in '$dimension'") + return Pair(value, unit) + } + + private companion object { + val parserPattern = Pattern.compile("(\\d+(\\.\\d+)?)([a-z]+)") + val dimensionMap = + mapOf( + "dp" to TypedValue.COMPLEX_UNIT_DIP, + "dip" to TypedValue.COMPLEX_UNIT_DIP, + "sp" to TypedValue.COMPLEX_UNIT_SP, + "px" to TypedValue.COMPLEX_UNIT_PX, + "pt" to TypedValue.COMPLEX_UNIT_PT, + "mm" to TypedValue.COMPLEX_UNIT_MM, + "in" to TypedValue.COMPLEX_UNIT_IN, + ) + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt new file mode 100644 index 000000000000..34cb4ef7089d --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt @@ -0,0 +1,32 @@ +/* + * 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.shared.clocks + +import com.android.systemui.log.core.LogLevel +import com.android.systemui.log.core.LogcatOnlyMessageBuffer +import com.android.systemui.log.core.Logger + +object LogUtil { + // Used when MessageBuffers are not provided by the host application + val DEFAULT_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.INFO) + + // Only intended for use during initialization steps where the correct logger doesn't exist yet + val FALLBACK_INIT_LOGGER = Logger(LogcatOnlyMessageBuffer(LogLevel.ERROR), "CLOCK_INIT") + + // Debug is primarially used for tests, but can also be used for tracking down hard issues. + val DEBUG_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.DEBUG) +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt new file mode 100644 index 000000000000..f5a9375122b5 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt @@ -0,0 +1,117 @@ +/* + * 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.shared.clocks + +import android.graphics.Typeface +import com.android.systemui.animation.TypefaceVariantCache +import com.android.systemui.log.core.Logger +import com.android.systemui.log.core.MessageBuffer +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +class TypefaceCache(messageBuffer: MessageBuffer, val typefaceFactory: (String) -> Typeface) { + private val logger = Logger(messageBuffer, this::class.simpleName!!) + + private data class CacheKey(val res: String, val fvar: String?) + + private inner class WeakTypefaceRef(val key: CacheKey, typeface: Typeface) : + WeakReference<Typeface>(typeface, queue) + + private var totalHits = 0 + + private var totalMisses = 0 + + private var totalEvictions = 0 + + // We use a map of WeakRefs here instead of an LruCache. This prevents needing to resize the + // cache depending on the number of distinct fonts used by a clock, as different clocks have + // different numbers of simultaneously loaded and configured fonts. Because our clocks tend to + // initialize a number of parallel views and animators, our usages of Typefaces overlap. As a + // result, once a typeface is no longer being used, it is unlikely to be recreated immediately. + private val cache = mutableMapOf<CacheKey, WeakTypefaceRef>() + private val queue = ReferenceQueue<Typeface>() + + fun getTypeface(res: String): Typeface { + checkQueue() + val key = CacheKey(res, null) + cache.get(key)?.get()?.let { + logHit(key) + return it + } + + logMiss(key) + val result = typefaceFactory(res) + cache.put(key, WeakTypefaceRef(key, result)) + return result + } + + fun getVariantCache(res: String): TypefaceVariantCache { + val baseTypeface = getTypeface(res) + return object : TypefaceVariantCache { + override fun getTypefaceForVariant(fvar: String?): Typeface? { + checkQueue() + val key = CacheKey(res, fvar) + cache.get(key)?.get()?.let { + logHit(key) + return it + } + + logMiss(key) + return TypefaceVariantCache.createVariantTypeface(baseTypeface, fvar).also { + cache.put(key, WeakTypefaceRef(key, it)) + } + } + } + } + + private fun logHit(key: CacheKey) { + totalHits++ + if (DEBUG_HITS) + logger.i({ "HIT: $str1; Total: $int1" }) { + str1 = key.toString() + int1 = totalHits + } + } + + private fun logMiss(key: CacheKey) { + totalMisses++ + logger.w({ "MISS: $str1; Total: $int1" }) { + str1 = key.toString() + int1 = totalMisses + } + } + + private fun logEviction(key: CacheKey) { + totalEvictions++ + logger.i({ "EVICTED: $str1; Total: $int1" }) { + str1 = key.toString() + int1 = totalEvictions + } + } + + private fun checkQueue() = + generateSequence { queue.poll() } + .filterIsInstance<WeakTypefaceRef>() + .forEach { + logEviction(it.key) + cache.remove(it.key) + } + + companion object { + private val DEBUG_HITS = false + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt new file mode 100644 index 000000000000..eb7234646a64 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.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.shared.clocks.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Point +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.VisibleForTesting +import com.android.systemui.log.core.Logger +import com.android.systemui.log.core.MessageBuffer +import com.android.systemui.plugins.clocks.AlarmData +import com.android.systemui.plugins.clocks.WeatherData +import com.android.systemui.plugins.clocks.ZenData +import com.android.systemui.shared.clocks.AssetLoader +import com.android.systemui.shared.clocks.LogUtil +import java.util.Locale + +abstract class DigitalClockFaceView(ctx: Context, messageBuffer: MessageBuffer) : FrameLayout(ctx) { + protected val logger = Logger(messageBuffer, this::class.simpleName!!) + get() = field ?: LogUtil.FALLBACK_INIT_LOGGER + + abstract var digitalClockTextViewMap: MutableMap<Int, SimpleDigitalClockTextView> + + @VisibleForTesting + var isAnimationEnabled = true + set(value) { + field = value + digitalClockTextViewMap.forEach { _, view -> view.isAnimationEnabled = value } + } + + var dozeFraction: Float = 0F + set(value) { + field = value + digitalClockTextViewMap.forEach { _, view -> view.dozeFraction = field } + } + + val dozeControlState = DozeControlState() + + var isReactiveTouchInteractionEnabled = false + set(value) { + field = value + } + + open val text: String? + get() = null + + open fun refreshTime() = logger.d("refreshTime()") + + override fun invalidate() { + logger.d("invalidate()") + super.invalidate() + } + + override fun requestLayout() { + logger.d("requestLayout()") + super.requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + logger.d("onMeasure()") + calculateSize(widthMeasureSpec, heightMeasureSpec)?.let { setMeasuredDimension(it.x, it.y) } + ?: run { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } + calculateLeftTopPosition() + dozeControlState.animateReady = true + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + logger.d("onLayout()") + super.onLayout(changed, left, top, right, bottom) + } + + override fun onDraw(canvas: Canvas) { + text?.let { logger.d({ "onDraw($str1)" }) { str1 = it } } ?: run { logger.d("onDraw()") } + super.onDraw(canvas) + } + + /* + * Called in onMeasure to generate width/height overrides to the normal measuring logic. A null + * result causes the normal view measuring logic to execute. + */ + protected open fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point? = null + + protected open fun calculateLeftTopPosition() {} + + override fun addView(child: View?) { + if (child == null) return + logger.d({ "addView($str1 @$int1)" }) { + str1 = child::class.simpleName!! + int1 = child.id + } + super.addView(child) + if (child is SimpleDigitalClockTextView) { + digitalClockTextViewMap[child.id] = child + } + child.setWillNotDraw(true) + } + + open fun animateDoze(isDozing: Boolean, isAnimated: Boolean) { + digitalClockTextViewMap.forEach { _, view -> view.animateDoze(isDozing, isAnimated) } + } + + open fun animateCharge() { + digitalClockTextViewMap.forEach { _, view -> view.animateCharge() } + } + + open fun onPositionUpdated(fromLeft: Int, direction: Int, fraction: Float) {} + + fun updateColors(assets: AssetLoader, isRegionDark: Boolean) { + digitalClockTextViewMap.forEach { _, view -> view.updateColors(assets, isRegionDark) } + invalidate() + } + + fun onFontSettingChanged(fontSizePx: Float) { + digitalClockTextViewMap.forEach { _, view -> view.applyTextSize(fontSizePx) } + } + + open val hasCustomWeatherDataDisplay + get() = false + + open val hasCustomPositionUpdatedAnimation + get() = false + + /** True if it's large weather clock, will use weatherBlueprint in compose */ + open val useCustomClockScene + get() = false + + // TODO: implement ClockEventUnion? + open fun onLocaleChanged(locale: Locale) {} + + open fun onWeatherDataChanged(data: WeatherData) {} + + open fun onAlarmDataChanged(data: AlarmData) {} + + open fun onZenDataChanged(data: ZenData) {} + + open fun onPickerCarouselSwiping(swipingFraction: Float) {} + + open fun isAlignedWithScreen(): Boolean = false + + /** + * animateDoze needs correct translate value, which is calculated in onMeasure so we need to + * delay this animation when we get correct values + */ + class DozeControlState { + var animateDoze: () -> Unit = {} + set(value) { + if (animateReady) { + value() + field = {} + } else { + field = value + } + } + + var animateReady = false + set(value) { + if (value) { + animateDoze() + animateDoze = {} + } + field = value + } + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt new file mode 100644 index 000000000000..c29c8dac8ba6 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt @@ -0,0 +1,260 @@ +/* + * 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.shared.clocks.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Point +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import com.android.app.animation.Interpolators +import com.android.systemui.customization.R +import com.android.systemui.log.core.MessageBuffer +import com.android.systemui.shared.clocks.AssetLoader +import com.android.systemui.shared.clocks.DigitTranslateAnimator +import com.android.systemui.shared.clocks.FontTextStyle +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +fun clamp(value: Float, minVal: Float, maxVal: Float): Float = max(min(value, maxVal), minVal) + +class FlexClockView(context: Context, val assetLoader: AssetLoader, messageBuffer: MessageBuffer) : + DigitalClockFaceView(context, messageBuffer) { + override var digitalClockTextViewMap = mutableMapOf<Int, SimpleDigitalClockTextView>() + val digitLeftTopMap = mutableMapOf<Int, Point>() + var maxSingleDigitHeight = -1 + var maxSingleDigitWidth = -1 + val lockscreenTranslate = Point(0, 0) + val aodTranslate = Point(0, 0) + + init { + setWillNotDraw(false) + layoutParams = + RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + } + + private var prevX = 0f + private var prevY = 0f + private var isDown = false + + // TODO(b/340253296): Genericize; json spec + private var wght = 603f + private var wdth = 100f + + // TODO(b/340253296): Json spec + private val MAX_WGHT = 950f + private val MIN_WGHT = 50f + private val WGHT_SCALE = 0.5f + + private val MAX_WDTH = 150f + private val MIN_WDTH = 0f + private val WDTH_SCALE = 0.2f + + override fun onTouchEvent(evt: MotionEvent): Boolean { + // TODO(b/340253296): implement on DigitalClockFaceView? + if (!isReactiveTouchInteractionEnabled) { + return super.onTouchEvent(evt) + } + + when (evt.action) { + MotionEvent.ACTION_DOWN -> { + isDown = true + prevX = evt.x + prevY = evt.y + return true + } + + MotionEvent.ACTION_MOVE -> { + if (!isDown) { + return super.onTouchEvent(evt) + } + + wdth = clamp(wdth + (evt.x - prevX) * WDTH_SCALE, MIN_WDTH, MAX_WDTH) + wght = clamp(wght + (evt.y - prevY) * WGHT_SCALE, MIN_WGHT, MAX_WGHT) + prevX = evt.x + prevY = evt.y + + // TODO(b/340253296): Genericize; json spec + val fvar = "'wght' $wght, 'wdth' $wdth, 'opsz' 144, 'ROND' 100" + digitalClockTextViewMap.forEach { (_, view) -> + val textStyle = view.textStyle as FontTextStyle + textStyle.fontVariation = fvar + view.applyStyles(assetLoader, textStyle, view.aodStyle) + } + + requestLayout() + invalidate() + return true + } + + MotionEvent.ACTION_UP -> { + isDown = false + return true + } + } + + return super.onTouchEvent(evt) + } + + override fun addView(child: View?) { + super.addView(child) + (child as SimpleDigitalClockTextView).digitTranslateAnimator = + DigitTranslateAnimator(::invalidate) + } + + protected override fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point { + digitalClockTextViewMap.forEach { (_, textView) -> + textView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + } + val textView = digitalClockTextViewMap[R.id.HOUR_FIRST_DIGIT]!! + maxSingleDigitHeight = textView.measuredHeight + maxSingleDigitWidth = textView.measuredWidth + aodTranslate.x = -(maxSingleDigitWidth * AOD_HORIZONTAL_TRANSLATE_RATIO).toInt() + aodTranslate.y = (maxSingleDigitHeight * AOD_VERTICAL_TRANSLATE_RATIO).toInt() + return Point( + ((maxSingleDigitWidth + abs(aodTranslate.x)) * 2), + ((maxSingleDigitHeight + abs(aodTranslate.y)) * 2), + ) + } + + protected override fun calculateLeftTopPosition() { + digitLeftTopMap[R.id.HOUR_FIRST_DIGIT] = Point(0, 0) + digitLeftTopMap[R.id.HOUR_SECOND_DIGIT] = Point(maxSingleDigitWidth, 0) + digitLeftTopMap[R.id.MINUTE_FIRST_DIGIT] = Point(0, maxSingleDigitHeight) + digitLeftTopMap[R.id.MINUTE_SECOND_DIGIT] = Point(maxSingleDigitWidth, maxSingleDigitHeight) + digitLeftTopMap.forEach { _, point -> + point.x += abs(aodTranslate.x) + point.y += abs(aodTranslate.y) + } + } + + override fun refreshTime() { + super.refreshTime() + digitalClockTextViewMap.forEach { (_, textView) -> textView.refreshText() } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + digitalClockTextViewMap.forEach { (id, _) -> + val textView = digitalClockTextViewMap[id]!! + canvas.translate(digitLeftTopMap[id]!!.x.toFloat(), digitLeftTopMap[id]!!.y.toFloat()) + textView.draw(canvas) + canvas.translate(-digitLeftTopMap[id]!!.x.toFloat(), -digitLeftTopMap[id]!!.y.toFloat()) + } + } + + override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) { + dozeControlState.animateDoze = { + super.animateDoze(isDozing, isAnimated) + if (maxSingleDigitHeight == -1) { + measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + } + digitalClockTextViewMap.forEach { (id, textView) -> + textView.digitTranslateAnimator?.let { + if (!isDozing) { + it.animatePosition( + animate = isAnimated && isAnimationEnabled, + interpolator = Interpolators.EMPHASIZED, + duration = AOD_TRANSITION_DURATION, + targetTranslation = + updateDirectionalTargetTranslate(id, lockscreenTranslate), + ) + } else { + it.animatePosition( + animate = isAnimated && isAnimationEnabled, + interpolator = Interpolators.EMPHASIZED, + duration = AOD_TRANSITION_DURATION, + onAnimationEnd = null, + targetTranslation = updateDirectionalTargetTranslate(id, aodTranslate), + ) + } + } + } + } + } + + override fun animateCharge() { + super.animateCharge() + digitalClockTextViewMap.forEach { (id, textView) -> + textView.digitTranslateAnimator?.let { + it.animatePosition( + animate = isAnimationEnabled, + interpolator = Interpolators.EMPHASIZED, + duration = CHARGING_TRANSITION_DURATION, + onAnimationEnd = { + it.animatePosition( + animate = isAnimationEnabled, + interpolator = Interpolators.EMPHASIZED, + duration = CHARGING_TRANSITION_DURATION, + targetTranslation = + updateDirectionalTargetTranslate( + id, + if (dozeFraction == 1F) aodTranslate else lockscreenTranslate, + ), + ) + }, + targetTranslation = + updateDirectionalTargetTranslate( + id, + if (dozeFraction == 1F) lockscreenTranslate else aodTranslate, + ), + ) + } + } + } + + companion object { + val AOD_TRANSITION_DURATION = 750L + val CHARGING_TRANSITION_DURATION = 300L + + val AOD_HORIZONTAL_TRANSLATE_RATIO = 0.15F + val AOD_VERTICAL_TRANSLATE_RATIO = 0.075F + + // Use the sign of targetTranslation to control the direction of digit translation + fun updateDirectionalTargetTranslate(id: Int, targetTranslation: Point): Point { + val outPoint = Point(targetTranslation) + when (id) { + R.id.HOUR_FIRST_DIGIT -> { + outPoint.x *= -1 + outPoint.y *= -1 + } + + R.id.HOUR_SECOND_DIGIT -> { + outPoint.x *= 1 + outPoint.y *= -1 + } + + R.id.MINUTE_FIRST_DIGIT -> { + outPoint.x *= -1 + outPoint.y *= 1 + } + + R.id.MINUTE_SECOND_DIGIT -> { + outPoint.x *= 1 + outPoint.y *= 1 + } + } + return outPoint + } + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt new file mode 100644 index 000000000000..74617b1c0c5c --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt @@ -0,0 +1,654 @@ +/* + * 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.shared.clocks.view + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Point +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.text.Layout +import android.text.TextPaint +import android.util.AttributeSet +import android.util.Log +import android.util.MathUtils +import android.util.TypedValue +import android.view.View.MeasureSpec.AT_MOST +import android.view.View.MeasureSpec.EXACTLY +import android.view.animation.Interpolator +import android.widget.TextView +import com.android.internal.annotations.VisibleForTesting +import com.android.systemui.animation.TextAnimator +import com.android.systemui.animation.TypefaceVariantCache +import com.android.systemui.customization.R +import com.android.systemui.log.core.Logger +import com.android.systemui.log.core.MessageBuffer +import com.android.systemui.shared.clocks.AssetLoader +import com.android.systemui.shared.clocks.ClockAnimation +import com.android.systemui.shared.clocks.DigitTranslateAnimator +import com.android.systemui.shared.clocks.DimensionParser +import com.android.systemui.shared.clocks.FontTextStyle +import com.android.systemui.shared.clocks.LogUtil +import com.android.systemui.shared.clocks.RenderType +import com.android.systemui.shared.clocks.TextStyle +import java.lang.Thread +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +private val TAG = SimpleDigitalClockTextView::class.simpleName!! + +@SuppressLint("AppCompatCustomView") +open class SimpleDigitalClockTextView( + ctx: Context, + messageBuffer: MessageBuffer, + attrs: AttributeSet? = null, +) : TextView(ctx, attrs), SimpleDigitalClockView { + val lockScreenPaint = TextPaint() + override lateinit var textStyle: FontTextStyle + lateinit var aodStyle: FontTextStyle + private val parser = DimensionParser(ctx) + var maxSingleDigitHeight = -1 + var maxSingleDigitWidth = -1 + var digitTranslateAnimator: DigitTranslateAnimator? = null + var aodFontSizePx: Float = -1F + var isVertical: Boolean = false + + // Store the font size when there's no height constraint as a reference when adjusting font size + private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE + // Calculated by height of styled text view / text size + // Used as a factor to calculate a smaller font size when text height is constrained + @VisibleForTesting var fontSizeAdjustFactor = 1F + + private val initThread = Thread.currentThread() + + // textBounds is the size of text in LS, which only measures current text in lockscreen style + var textBounds = Rect() + // prevTextBounds and targetTextBounds are to deal with dozing animation between LS and AOD + // especially for the textView which has different bounds during the animation + // prevTextBounds holds the state we are transitioning from + private val prevTextBounds = Rect() + // targetTextBounds holds the state we are interpolating to + private val targetTextBounds = Rect() + protected val logger = Logger(messageBuffer, this::class.simpleName!!) + get() = field ?: LogUtil.FALLBACK_INIT_LOGGER + + private var aodDozingInterpolator: Interpolator? = null + + @VisibleForTesting lateinit var textAnimator: TextAnimator + @VisibleForTesting var outlineAnimator: TextAnimator? = null + // used for hollow style for AOD version + // because stroke style for some fonts have some unwanted inner strokes + // we want to draw this layer on top to oclude them + @VisibleForTesting var innerAnimator: TextAnimator? = null + + lateinit var typefaceCache: TypefaceVariantCache + private set + + private fun setTypefaceCache(value: TypefaceVariantCache) { + typefaceCache = value + if (this::textAnimator.isInitialized) { + textAnimator.typefaceCache = value + } + outlineAnimator?.typefaceCache = value + innerAnimator?.typefaceCache = value + } + + @VisibleForTesting + var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb -> + TextAnimator(layout, ClockAnimation.NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb).also { + if (this::typefaceCache.isInitialized) { + it.typefaceCache = typefaceCache + } + } + } + + override var verticalAlignment: VerticalAlignment = VerticalAlignment.CENTER + override var horizontalAlignment: HorizontalAlignment = HorizontalAlignment.LEFT + override var isAnimationEnabled = true + override var dozeFraction: Float = 0F + set(value) { + field = value + invalidate() + } + + // Have to passthrough to unify View with SimpleDigitalClockView + override var text: String + get() = super.getText().toString() + set(value) = super.setText(value) + + var textBorderWidth = 0F + var aodBorderWidth = 0F + var baselineFromMeasure = 0 + + var textFillColor: Int? = null + var textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR + var aodFillColor = AOD_DEFAULT_COLOR + var aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR + + override fun updateColors(assets: AssetLoader, isRegionDark: Boolean) { + val fillColor = if (isRegionDark) textStyle.fillColorLight else textStyle.fillColorDark + textFillColor = + fillColor?.let { assets.readColor(it) } + ?: assets.seedColor + ?: getDefaultColor(assets, isRegionDark) + // for NumberOverlapView to read correct color + lockScreenPaint.color = textFillColor as Int + textStyle.outlineColor?.let { textOutlineColor = assets.readColor(it) } + ?: run { textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR } + (aodStyle.fillColorLight ?: aodStyle.fillColorDark)?.let { + aodFillColor = assets.readColor(it) + } ?: run { aodFillColor = AOD_DEFAULT_COLOR } + aodStyle.outlineColor?.let { aodOutlineColor = assets.readColor(it) } + ?: run { aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR } + if (dozeFraction < 1f) { + textAnimator.setTextStyle(color = textFillColor, animate = false) + outlineAnimator?.setTextStyle(color = textOutlineColor, animate = false) + } + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + logger.d("onMeasure()") + if (isVertical) { + // use at_most to avoid apply measuredWidth from last measuring to measuredHeight + // cause we use max to setMeasuredDimension + super.onMeasure( + MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), AT_MOST), + MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), AT_MOST), + ) + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + val layout = this.layout + if (layout != null) { + if (!this::textAnimator.isInitialized) { + textAnimator = textAnimatorFactory(layout, ::invalidate) + outlineAnimator = textAnimatorFactory(layout) {} + innerAnimator = textAnimatorFactory(layout) {} + setInterpolatorPaint() + } else { + textAnimator.updateLayout(layout) + outlineAnimator?.updateLayout(layout) + innerAnimator?.updateLayout(layout) + } + baselineFromMeasure = layout.getLineBaseline(0) + } else { + val currentThread = Thread.currentThread() + Log.wtf( + TAG, + "TextView.getLayout() is null after measure! " + + "currentThread=$currentThread; initThread=$initThread", + ) + } + + var expectedWidth: Int + var expectedHeight: Int + + if (MeasureSpec.getMode(heightMeasureSpec) == EXACTLY) { + // For view which has fixed height, e.g. small clock, + // we should always return the size required from parent view + expectedHeight = heightMeasureSpec + } else { + expectedHeight = + MeasureSpec.makeMeasureSpec( + if (isSingleDigit()) { + maxSingleDigitHeight + } else { + textBounds.height() + 2 * lockScreenPaint.strokeWidth.toInt() + }, + MeasureSpec.getMode(measuredHeight), + ) + } + if (MeasureSpec.getMode(widthMeasureSpec) == EXACTLY) { + expectedWidth = widthMeasureSpec + } else { + expectedWidth = + MeasureSpec.makeMeasureSpec( + if (isSingleDigit()) { + maxSingleDigitWidth + } else { + max( + textBounds.width() + 2 * lockScreenPaint.strokeWidth.toInt(), + MeasureSpec.getSize(measuredWidth), + ) + }, + MeasureSpec.getMode(measuredWidth), + ) + } + + if (isVertical) { + expectedWidth = expectedHeight.also { expectedHeight = expectedWidth } + } + setMeasuredDimension(expectedWidth, expectedHeight) + } + + override fun onDraw(canvas: Canvas) { + if (isVertical) { + canvas.save() + canvas.translate(0F, measuredHeight.toFloat()) + canvas.rotate(-90F) + } + logger.d({ "onDraw(); ls: $str1; aod: $str2;" }) { + str1 = textAnimator.textInterpolator.shapedText + str2 = outlineAnimator?.textInterpolator?.shapedText + } + val translation = getLocalTranslation() + canvas.translate(translation.x.toFloat(), translation.y.toFloat()) + digitTranslateAnimator?.let { + canvas.translate(it.updatedTranslate.x.toFloat(), it.updatedTranslate.y.toFloat()) + } + + if (aodStyle.renderType == RenderType.HOLLOW_TEXT) { + canvas.saveLayer( + -translation.x.toFloat(), + -translation.y.toFloat(), + (-translation.x + measuredWidth).toFloat(), + (-translation.y + measuredHeight).toFloat(), + null, + ) + outlineAnimator?.draw(canvas) + canvas.saveLayer( + -translation.x.toFloat(), + -translation.y.toFloat(), + (-translation.x + measuredWidth).toFloat(), + (-translation.y + measuredHeight).toFloat(), + Paint().also { it.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) }, + ) + innerAnimator?.draw(canvas) + canvas.restore() + canvas.restore() + } else if (aodStyle.renderType != RenderType.CHANGE_WEIGHT) { + outlineAnimator?.draw(canvas) + } + textAnimator.draw(canvas) + + digitTranslateAnimator?.let { + canvas.translate(-it.updatedTranslate.x.toFloat(), -it.updatedTranslate.y.toFloat()) + } + canvas.translate(-translation.x.toFloat(), -translation.y.toFloat()) + if (isVertical) { + canvas.restore() + } + } + + override fun invalidate() { + logger.d("invalidate()") + super.invalidate() + (parent as? DigitalClockFaceView)?.invalidate() + } + + override fun refreshTime() { + logger.d("refreshTime()") + refreshText() + } + + override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) { + if (!this::textAnimator.isInitialized) { + return + } + val fvar = if (isDozing) aodStyle.fontVariation else textStyle.fontVariation + textAnimator.setTextStyle( + animate = isAnimated && isAnimationEnabled, + color = if (isDozing) aodFillColor else textFillColor, + textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize, + fvar = fvar, + duration = aodStyle.transitionDuration, + interpolator = aodDozingInterpolator, + ) + updateTextBoundsForTextAnimator() + outlineAnimator?.setTextStyle( + animate = isAnimated && isAnimationEnabled, + color = if (isDozing) aodOutlineColor else textOutlineColor, + textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize, + fvar = fvar, + strokeWidth = if (isDozing) aodBorderWidth else textBorderWidth, + duration = aodStyle.transitionDuration, + interpolator = aodDozingInterpolator, + ) + innerAnimator?.setTextStyle( + animate = isAnimated && isAnimationEnabled, + color = Color.WHITE, + textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize, + fvar = fvar, + duration = aodStyle.transitionDuration, + interpolator = aodDozingInterpolator, + ) + } + + override fun animateCharge() { + if (!this::textAnimator.isInitialized || textAnimator.isRunning()) { + // Skip charge animation if dozing animation is already playing. + return + } + logger.d("animateCharge()") + val middleFvar = if (dozeFraction == 0F) aodStyle.fontVariation else textStyle.fontVariation + val endFvar = if (dozeFraction == 0F) textStyle.fontVariation else aodStyle.fontVariation + val startAnimPhase2 = Runnable { + textAnimator.setTextStyle(fvar = endFvar, animate = isAnimationEnabled) + outlineAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled) + innerAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled) + updateTextBoundsForTextAnimator() + } + textAnimator.setTextStyle( + fvar = middleFvar, + animate = isAnimationEnabled, + onAnimationEnd = startAnimPhase2, + ) + outlineAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled) + innerAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled) + updateTextBoundsForTextAnimator() + } + + fun refreshText() { + lockScreenPaint.getTextBounds(text, 0, text.length, textBounds) + if (this::textAnimator.isInitialized) { + textAnimator.textInterpolator.targetPaint.getTextBounds( + text, + 0, + text.length, + targetTextBounds, + ) + } + if (layout == null) { + requestLayout() + } else { + textAnimator.updateLayout(layout) + outlineAnimator?.updateLayout(layout) + innerAnimator?.updateLayout(layout) + } + } + + private fun isSingleDigit(): Boolean { + return id == R.id.HOUR_FIRST_DIGIT || + id == R.id.HOUR_SECOND_DIGIT || + id == R.id.MINUTE_FIRST_DIGIT || + id == R.id.MINUTE_SECOND_DIGIT + } + + private fun updateInterpolatedTextBounds(): Rect { + val interpolatedTextBounds = Rect() + if (textAnimator.animator.animatedFraction != 1.0f && textAnimator.animator.isRunning) { + interpolatedTextBounds.left = + MathUtils.lerp( + prevTextBounds.left, + targetTextBounds.left, + textAnimator.animator.animatedValue as Float, + ) + .toInt() + + interpolatedTextBounds.right = + MathUtils.lerp( + prevTextBounds.right, + targetTextBounds.right, + textAnimator.animator.animatedValue as Float, + ) + .toInt() + + interpolatedTextBounds.top = + MathUtils.lerp( + prevTextBounds.top, + targetTextBounds.top, + textAnimator.animator.animatedValue as Float, + ) + .toInt() + + interpolatedTextBounds.bottom = + MathUtils.lerp( + prevTextBounds.bottom, + targetTextBounds.bottom, + textAnimator.animator.animatedValue as Float, + ) + .toInt() + } else { + interpolatedTextBounds.set(targetTextBounds) + } + return interpolatedTextBounds + } + + private fun updateXtranslation(inPoint: Point, interpolatedTextBounds: Rect): Point { + val viewWidth = if (isVertical) measuredHeight else measuredWidth + when (horizontalAlignment) { + HorizontalAlignment.LEFT -> { + inPoint.x = lockScreenPaint.strokeWidth.toInt() - interpolatedTextBounds.left + } + HorizontalAlignment.RIGHT -> { + inPoint.x = + viewWidth - interpolatedTextBounds.right - lockScreenPaint.strokeWidth.toInt() + } + HorizontalAlignment.CENTER -> { + inPoint.x = + (viewWidth - interpolatedTextBounds.width()) / 2 - interpolatedTextBounds.left + } + } + return inPoint + } + + // translation of reference point of text + // used for translation when calling textInterpolator + fun getLocalTranslation(): Point { + val viewHeight = if (isVertical) measuredWidth else measuredHeight + val interpolatedTextBounds = updateInterpolatedTextBounds() + val localTranslation = Point(0, 0) + val correctedBaseline = if (baseline != -1) baseline else baselineFromMeasure + // get the change from current baseline to expected baseline + when (verticalAlignment) { + VerticalAlignment.CENTER -> { + localTranslation.y = + ((viewHeight - interpolatedTextBounds.height()) / 2 - + interpolatedTextBounds.top - + correctedBaseline) + } + VerticalAlignment.TOP -> { + localTranslation.y = + (-interpolatedTextBounds.top + lockScreenPaint.strokeWidth - correctedBaseline) + .toInt() + } + VerticalAlignment.BOTTOM -> { + localTranslation.y = + viewHeight - + interpolatedTextBounds.bottom - + lockScreenPaint.strokeWidth.toInt() - + correctedBaseline + } + VerticalAlignment.BASELINE -> { + localTranslation.y = -lockScreenPaint.strokeWidth.toInt() + } + } + + return updateXtranslation(localTranslation, interpolatedTextBounds) + } + + override fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?) { + this.textStyle = textStyle as FontTextStyle + val typefaceName = "fonts/" + textStyle.fontFamily + setTypefaceCache(assets.typefaceCache.getVariantCache(typefaceName)) + lockScreenPaint.strokeJoin = Paint.Join.ROUND + lockScreenPaint.typeface = typefaceCache.getTypefaceForVariant(textStyle.fontVariation) + textStyle.fontFeatureSettings?.let { + lockScreenPaint.fontFeatureSettings = it + fontFeatureSettings = it + } + typeface = lockScreenPaint.typeface + textStyle.lineHeight?.let { lineHeight = it.toInt() } + // borderWidth in textStyle and aodStyle is used to draw, + // strokeWidth in lockScreenPaint is used to measure and get enough space for the text + textStyle.borderWidth?.let { textBorderWidth = parser.convert(it) } + + if (aodStyle != null && aodStyle is FontTextStyle) { + this.aodStyle = aodStyle + } else { + this.aodStyle = textStyle.copy() + } + this.aodStyle.transitionInterpolator?.let { aodDozingInterpolator = it.interpolator } + aodBorderWidth = parser.convert(this.aodStyle.borderWidth ?: DEFAULT_AOD_STROKE_WIDTH) + lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth)) + measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + setInterpolatorPaint() + recomputeMaxSingleDigitSizes() + invalidate() + } + + // When constrainedByHeight is on, targetFontSizePx is the constrained height of textView + override fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean) { + val adjustedFontSizePx = adjustFontSize(targetFontSizePx, constrainedByHeight) + val fontSizePx = adjustedFontSizePx * (textStyle.fontSizeScale ?: 1f) + aodFontSizePx = + adjustedFontSizePx * (aodStyle.fontSizeScale ?: textStyle.fontSizeScale ?: 1f) + if (fontSizePx > 0) { + setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx) + lockScreenPaint.textSize = textSize + lockScreenPaint.getTextBounds(text, 0, text.length, textBounds) + targetTextBounds.set(textBounds) + } + if (!constrainedByHeight) { + val lastUnconstrainedHeight = textBounds.height() + lockScreenPaint.strokeWidth * 2 + fontSizeAdjustFactor = lastUnconstrainedHeight / lastUnconstrainedTextSize + } + textStyle.borderWidthScale?.let { + textBorderWidth = fontSizePx * it + if (dozeFraction < 1.0F) { + outlineAnimator?.setTextStyle(strokeWidth = textBorderWidth, animate = false) + } + } + aodStyle.borderWidthScale?.let { + aodBorderWidth = fontSizePx * it + if (dozeFraction > 0.0F) { + outlineAnimator?.setTextStyle(strokeWidth = aodBorderWidth, animate = false) + } + } + + lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth)) + recomputeMaxSingleDigitSizes() + + if (this::textAnimator.isInitialized) { + textAnimator.setTextStyle(textSize = lockScreenPaint.textSize, animate = false) + } + outlineAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false) + innerAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false) + } + + private fun recomputeMaxSingleDigitSizes() { + val rectForCalculate = Rect() + maxSingleDigitHeight = 0 + maxSingleDigitWidth = 0 + + for (i in 0..9) { + lockScreenPaint.getTextBounds(i.toString(), 0, 1, rectForCalculate) + maxSingleDigitHeight = max(maxSingleDigitHeight, rectForCalculate.height()) + maxSingleDigitWidth = max(maxSingleDigitWidth, rectForCalculate.width()) + } + maxSingleDigitWidth += 2 * lockScreenPaint.strokeWidth.toInt() + maxSingleDigitHeight += 2 * lockScreenPaint.strokeWidth.toInt() + } + + // called without animation, can be used to set the initial state of animator + private fun setInterpolatorPaint() { + if (this::textAnimator.isInitialized) { + // set initial style + textAnimator.textInterpolator.targetPaint.set(lockScreenPaint) + textAnimator.textInterpolator.onTargetPaintModified() + textAnimator.setTextStyle( + fvar = textStyle.fontVariation, + textSize = lockScreenPaint.textSize, + color = textFillColor, + animate = false, + ) + } + + if (outlineAnimator != null) { + outlineAnimator!! + .textInterpolator + .targetPaint + .set( + TextPaint(lockScreenPaint).also { + it.style = + if (aodStyle.renderType == RenderType.HOLLOW_TEXT) + Paint.Style.FILL_AND_STROKE + else Paint.Style.STROKE + } + ) + outlineAnimator!!.textInterpolator.onTargetPaintModified() + outlineAnimator!!.setTextStyle( + fvar = aodStyle.fontVariation, + textSize = lockScreenPaint.textSize, + color = Color.TRANSPARENT, + animate = false, + ) + } + + if (innerAnimator != null) { + innerAnimator!! + .textInterpolator + .targetPaint + .set(TextPaint(lockScreenPaint).also { it.style = Paint.Style.FILL }) + innerAnimator!!.textInterpolator.onTargetPaintModified() + innerAnimator!!.setTextStyle( + fvar = aodStyle.fontVariation, + textSize = lockScreenPaint.textSize, + color = Color.WHITE, + animate = false, + ) + } + } + + /* Called after textAnimator.setTextStyle + * textAnimator.setTextStyle will update targetPaint, + * and rebase if previous animator is canceled + * so basePaint will store the state we transition from + * and targetPaint will store the state we transition to + */ + private fun updateTextBoundsForTextAnimator() { + textAnimator.textInterpolator.basePaint.getTextBounds(text, 0, text.length, prevTextBounds) + textAnimator.textInterpolator.targetPaint.getTextBounds( + text, + 0, + text.length, + targetTextBounds, + ) + } + + /* + * Adjust text size to adapt to large display / font size + * where the text view will be constrained by height + */ + private fun adjustFontSize(targetFontSizePx: Float?, constrainedByHeight: Boolean): Float { + return if (constrainedByHeight) { + min((targetFontSizePx ?: 0F) / fontSizeAdjustFactor, lastUnconstrainedTextSize) + } else { + lastUnconstrainedTextSize = targetFontSizePx ?: 1F + lastUnconstrainedTextSize + } + } + + companion object { + val DEFAULT_AOD_STROKE_WIDTH = "2dp" + val TEXT_OUTLINE_DEFAULT_COLOR = Color.TRANSPARENT + val AOD_DEFAULT_COLOR = Color.TRANSPARENT + val AOD_OUTLINE_DEFAULT_COLOR = Color.WHITE + private val DEFAULT_LIGHT_COLOR = "@android:color/system_accent1_100+0" + private val DEFAULT_DARK_COLOR = "@android:color/system_accent2_600+0" + + fun getDefaultColor(assets: AssetLoader, isRegionDark: Boolean) = + assets.readColor(if (isRegionDark) DEFAULT_LIGHT_COLOR else DEFAULT_DARK_COLOR) + } +} diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt new file mode 100644 index 000000000000..bbd2d3d1f782 --- /dev/null +++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt @@ -0,0 +1,55 @@ +/* + * 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.shared.clocks.view + +import androidx.annotation.VisibleForTesting +import com.android.systemui.shared.clocks.AssetLoader +import com.android.systemui.shared.clocks.TextStyle + +interface SimpleDigitalClockView { + var text: String + var verticalAlignment: VerticalAlignment + var horizontalAlignment: HorizontalAlignment + var dozeFraction: Float + val textStyle: TextStyle + @VisibleForTesting var isAnimationEnabled: Boolean + + fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?) + + fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean = false) + + fun updateColors(assets: AssetLoader, isRegionDark: Boolean) + + fun refreshTime() + + fun animateCharge() + + fun animateDoze(isDozing: Boolean, isAnimated: Boolean) +} + +enum class VerticalAlignment { + TOP, + BOTTOM, + BASELINE, // default + CENTER, +} + +enum class HorizontalAlignment { + LEFT, + RIGHT, + CENTER, // default +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt index 8f9e23824809..8b1341114c68 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt @@ -145,7 +145,7 @@ class KeyboardRepositoryTest : SysuiTestCase() { fakeInputManager.addPhysicalKeyboard( PHYSICAL_NOT_FULL_KEYBOARD_ID, - isFullKeyboard = false + isFullKeyboard = false, ) assertThat(isKeyboardConnected).isFalse() @@ -223,7 +223,7 @@ class KeyboardRepositoryTest : SysuiTestCase() { backlightListenerCaptor.value.onBacklightChanged( current = 1, max = 5, - triggeredByKeyPress = false + triggeredByKeyPress = false, ) assertThat(backlight).isNull() } @@ -239,7 +239,7 @@ class KeyboardRepositoryTest : SysuiTestCase() { backlightListenerCaptor.value.onBacklightChanged( current = 1, max = 5, - triggeredByKeyPress = true + triggeredByKeyPress = true, ) assertThat(backlight).isNotNull() } @@ -318,15 +318,75 @@ class KeyboardRepositoryTest : SysuiTestCase() { } } + @Test + fun connectedKeyboards_emitsAllKeyboards() { + testScope.runTest { + val firstKeyboard = Keyboard(vendorId = 1, productId = 1) + val secondKeyboard = Keyboard(vendorId = 2, productId = 2) + captureDeviceListener() + val keyboards by collectLastValueImmediately(underTest.connectedKeyboards) + + fakeInputManager.addPhysicalKeyboard( + PHYSICAL_FULL_KEYBOARD_ID, + vendorId = firstKeyboard.vendorId, + productId = firstKeyboard.productId, + ) + assertThat(keyboards) + .containsExactly(Keyboard(firstKeyboard.vendorId, firstKeyboard.productId)) + + fakeInputManager.addPhysicalKeyboard( + ANOTHER_PHYSICAL_FULL_KEYBOARD_ID, + vendorId = secondKeyboard.vendorId, + productId = secondKeyboard.productId, + ) + assertThat(keyboards) + .containsExactly( + Keyboard(firstKeyboard.vendorId, firstKeyboard.productId), + Keyboard(secondKeyboard.vendorId, secondKeyboard.productId), + ) + } + } + + @Test + fun connectedKeyboards_emitsOnlyFullPhysicalKeyboards() { + testScope.runTest { + captureDeviceListener() + val keyboards by collectLastValueImmediately(underTest.connectedKeyboards) + + fakeInputManager.addPhysicalKeyboard(PHYSICAL_FULL_KEYBOARD_ID) + fakeInputManager.addDevice(VIRTUAL_FULL_KEYBOARD_ID, SOURCE_KEYBOARD) + fakeInputManager.addPhysicalKeyboard( + PHYSICAL_NOT_FULL_KEYBOARD_ID, + isFullKeyboard = false, + ) + + assertThat(keyboards).hasSize(1) + } + } + + @Test + fun connectedKeyboards_emitsOnlyConnectedKeyboards() { + testScope.runTest { + captureDeviceListener() + val keyboards by collectLastValueImmediately(underTest.connectedKeyboards) + + fakeInputManager.addPhysicalKeyboard(PHYSICAL_FULL_KEYBOARD_ID) + fakeInputManager.addPhysicalKeyboard(ANOTHER_PHYSICAL_FULL_KEYBOARD_ID) + fakeInputManager.removeDevice(ANOTHER_PHYSICAL_FULL_KEYBOARD_ID) + + assertThat(keyboards).hasSize(1) + } + } + private fun KeyboardBacklightListener.onBacklightChanged( current: Int, max: Int, - triggeredByKeyPress: Boolean = true + triggeredByKeyPress: Boolean = true, ) { onKeyboardBacklightChanged( /* deviceId= */ 0, TestBacklightState(current, max), - triggeredByKeyPress + triggeredByKeyPress, ) } @@ -343,7 +403,7 @@ class KeyboardRepositoryTest : SysuiTestCase() { private class TestBacklightState( private val brightnessLevel: Int, - private val maxBrightnessLevel: Int + private val maxBrightnessLevel: Int, ) : KeyboardBacklightState() { override fun getBrightnessLevel() = brightnessLevel diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java index aabfbd11b70e..65c01ed9eecd 100644 --- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java +++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java @@ -710,9 +710,16 @@ public class ClipboardOverlayController implements ClipboardListener.ClipboardOv @Override public void onShareButtonTapped() { if (clipboardSharedTransitions()) { - if (mClipboardModel.getType() != ClipboardModel.Type.OTHER) { - finishWithSharedTransition(CLIPBOARD_OVERLAY_SHARE_TAPPED, - IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext)); + switch (mClipboardModel.getType()) { + case TEXT: + case URI: + finish(CLIPBOARD_OVERLAY_SHARE_TAPPED, + IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext)); + break; + case IMAGE: + finishWithSharedTransition(CLIPBOARD_OVERLAY_SHARE_TAPPED, + IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext)); + break; } } } diff --git a/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt b/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt index 5a008bddc748..7711c48924cc 100644 --- a/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt @@ -38,7 +38,7 @@ class InputDeviceRepository constructor( @Background private val backgroundHandler: Handler, @Background private val backgroundScope: CoroutineScope, - private val inputManager: InputManager + private val inputManager: InputManager, ) { sealed interface DeviceChange @@ -50,11 +50,11 @@ constructor( data object FreshStart : DeviceChange /** - * Emits collection of all currently connected keyboards and what was the last [DeviceChange]. - * It emits collection so that every new subscriber to this SharedFlow can get latest state of - * all keyboards. Otherwise we might get into situation where subscriber timing on - * initialization matter and later subscriber will only get latest device and will miss all - * previous devices. + * Emits collection of all currently connected input devices and what was the last + * [DeviceChange]. It emits collection so that every new subscriber to this SharedFlow can get + * latest state of all input devices. Otherwise we might get into situation where subscriber + * timing on initialization matter and later subscriber will only get latest device and will + * miss all previous devices. */ // TODO(b/351984587): Replace with StateFlow @SuppressLint("SharedFlowCreation") @@ -79,11 +79,7 @@ constructor( inputManager.registerInputDeviceListener(listener, backgroundHandler) awaitClose { inputManager.unregisterInputDeviceListener(listener) } } - .shareIn( - scope = backgroundScope, - started = SharingStarted.Lazily, - replay = 1, - ) + .shareIn(scope = backgroundScope, started = SharingStarted.Lazily, replay = 1) private fun <T> SendChannel<T>.sendWithLogging(element: T) { trySendWithFailureLogging(element, TAG) diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt index f49cfdda8b0a..021c069008c3 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt @@ -50,6 +50,8 @@ class CommandLineKeyboardRepository @Inject constructor(commandRegistry: Command private val _newlyConnectedKeyboard: MutableStateFlow<Keyboard?> = MutableStateFlow(null) override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.filterNotNull() + override val connectedKeyboards: Flow<Set<Keyboard>> = MutableStateFlow(emptySet()) + init { Log.i(TAG, "initializing shell command $COMMAND") commandRegistry.registerCommand(COMMAND) { KeyboardCommand() } diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt index a20dfa5a4c3e..3329fe29dade 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt @@ -61,6 +61,9 @@ interface KeyboardRepository { */ val newlyConnectedKeyboard: Flow<Keyboard> + /** Emits set of currently connected keyboards */ + val connectedKeyboards: Flow<Set<Keyboard>> + /** * Emits [BacklightModel] whenever user changes backlight level from keyboard press. Can only * happen when physical keyboard is connected @@ -74,7 +77,7 @@ class KeyboardRepositoryImpl constructor( @Background private val backgroundDispatcher: CoroutineDispatcher, private val inputManager: InputManager, - inputDeviceRepository: InputDeviceRepository + inputDeviceRepository: InputDeviceRepository, ) : KeyboardRepository { @FlowPreview @@ -93,6 +96,13 @@ constructor( .mapNotNull { deviceIdToKeyboard(it) } .flowOn(backgroundDispatcher) + override val connectedKeyboards: Flow<Set<Keyboard>> = + inputDeviceRepository.deviceChange + .map { (deviceIds, _) -> deviceIds } + .map { deviceIds -> deviceIds.filter { isPhysicalFullKeyboard(it) } } + .distinctUntilChanged() + .map { deviceIds -> deviceIds.mapNotNull { deviceIdToKeyboard(it) }.toSet() } + override val isAnyKeyboardConnected: Flow<Boolean> = inputDeviceRepository.deviceChange .map { (ids, _) -> ids.any { id -> isPhysicalFullKeyboard(id) } } diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java index 4f47536f6b32..f83548ddbf45 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java +++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java @@ -994,7 +994,9 @@ public class NotificationShadeWindowControllerImpl implements NotificationShadeW // be dropped, causing the shade expansion to fail silently. Since the shade doesn't open, // it doesn't become visible, and the bounds will never update. Therefore, we must detect // the incorrect bounds here and force the update so that touches are routed correctly. - if (SceneContainerFlag.isEnabled() && mWindowRootView.getVisibility() == View.INVISIBLE) { + if (SceneContainerFlag.isEnabled() + && mWindowRootView != null + && mWindowRootView.getVisibility() == View.INVISIBLE) { Rect bounds = newConfig.windowConfiguration.getBounds(); if (mWindowRootView.getWidth() != bounds.width()) { mLogger.logConfigChangeWidthAdjust(mWindowRootView.getWidth(), bounds.width()); diff --git a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java index 079c72f049a6..1f92bc1df9c8 100644 --- a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java +++ b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java @@ -37,11 +37,8 @@ import android.media.AudioManager; import android.media.AudioSystem; import android.media.IAudioService; import android.media.IVolumeController; -import android.media.MediaRoute2Info; import android.media.MediaRouter2Manager; -import android.media.RoutingSessionInfo; import android.media.VolumePolicy; -import android.media.session.MediaController; import android.media.session.MediaController.PlaybackInfo; import android.media.session.MediaSession.Token; import android.net.Uri; @@ -88,7 +85,6 @@ import dalvik.annotation.optimization.NeverCompile; import java.io.PrintWriter; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -217,7 +213,7 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa VolumeDialogControllerImpl.class.getSimpleName()); mWorker = new W(mWorkerLooper); mRouter2Manager = MediaRouter2Manager.getInstance(mContext); - mMediaSessionsCallbacksW = new MediaSessionsCallbacks(mContext); + mMediaSessionsCallbacksW = new MediaSessionsCallbacks(); mMediaSessions = createMediaSessions(mContext, mWorkerLooper, mMediaSessionsCallbacksW); mAudioSharingInteractor = audioSharingInteractor; mJavaAdapter = javaAdapter; @@ -1360,16 +1356,9 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa private final HashMap<Token, Integer> mRemoteStreams = new HashMap<>(); private int mNextStream = DYNAMIC_STREAM_REMOTE_START_INDEX; - private final boolean mVolumeAdjustmentForRemoteGroupSessions; - - public MediaSessionsCallbacks(Context context) { - mVolumeAdjustmentForRemoteGroupSessions = context.getResources().getBoolean( - com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions); - } @Override public void onRemoteUpdate(Token token, String name, PlaybackInfo pi) { - if (showForSession(token)) { addStream(token, "onRemoteUpdate"); int stream = 0; @@ -1396,12 +1385,10 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa Log.d(TAG, "onRemoteUpdate: " + name + ": " + ss.level + " of " + ss.levelMax); mCallbacks.onStateChanged(mState); } - } } @Override public void onRemoteVolumeChanged(Token token, int flags) { - if (showForSession(token)) { addStream(token, "onRemoteVolumeChanged"); int stream = 0; synchronized (mRemoteStreams) { @@ -1420,27 +1407,27 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa if (showUI) { onShowRequestedW(Events.SHOW_REASON_REMOTE_VOLUME_CHANGED); } - } } @Override public void onRemoteRemoved(Token token) { - if (showForSession(token)) { - int stream = 0; - synchronized (mRemoteStreams) { - if (!mRemoteStreams.containsKey(token)) { - Log.d(TAG, "onRemoteRemoved: stream doesn't exist, " - + "aborting remote removed for token:" + token.toString()); - return; - } - stream = mRemoteStreams.get(token); - } - mState.states.remove(stream); - if (mState.activeStream == stream) { - updateActiveStreamW(-1); + int stream; + synchronized (mRemoteStreams) { + if (!mRemoteStreams.containsKey(token)) { + Log.d( + TAG, + "onRemoteRemoved: stream doesn't exist, " + + "aborting remote removed for token:" + + token.toString()); + return; } - mCallbacks.onStateChanged(mState); + stream = mRemoteStreams.get(token); + } + mState.states.remove(stream); + if (mState.activeStream == stream) { + updateActiveStreamW(-1); } + mCallbacks.onStateChanged(mState); } public void setStreamVolume(int stream, int level) { @@ -1449,39 +1436,7 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa Log.w(TAG, "setStreamVolume: No token found for stream: " + stream); return; } - if (showForSession(token)) { - mMediaSessions.setVolume(token, level); - } - } - - private boolean showForSession(Token token) { - if (mVolumeAdjustmentForRemoteGroupSessions) { - if (DEBUG) { - Log.d(TAG, "Volume adjustment for remote group sessions allowed," - + " showForSession: true"); - } - return true; - } - MediaController ctr = new MediaController(mContext, token); - String packageName = ctr.getPackageName(); - List<RoutingSessionInfo> sessions = - mRouter2Manager.getRoutingSessions(packageName); - if (DEBUG) { - Log.d(TAG, "Found " + sessions.size() + " routing sessions for package name " - + packageName); - } - for (RoutingSessionInfo session : sessions) { - if (DEBUG) { - Log.d(TAG, "Found routingSessionInfo: " + session); - } - if (!session.isSystemSession() - && session.getVolumeHandling() != MediaRoute2Info.PLAYBACK_VOLUME_FIXED) { - return true; - } - } - - Log.d(TAG, "No routing session for " + packageName); - return false; + mMediaSessions.setVolume(token, level); } private Token findToken(int stream) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java index 5fc19711a26c..8075d117a58a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java @@ -104,6 +104,8 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { @Mock private Animator mAnimator; + @Mock + private Animator mEndAnimator; private ArgumentCaptor<Animator.AnimatorListener> mAnimatorListenerCaptor = ArgumentCaptor.forClass(Animator.AnimatorListener.class); @@ -123,7 +125,7 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { MockitoAnnotations.initMocks(this); when(mClipboardOverlayView.getEnterAnimation()).thenReturn(mAnimator); - when(mClipboardOverlayView.getExitAnimation()).thenReturn(mAnimator); + when(mClipboardOverlayView.getExitAnimation()).thenReturn(mEndAnimator); when(mClipboardOverlayView.getFadeOutAnimation()).thenReturn(mAnimator); when(mClipboardOverlayWindow.getWindowInsets()).thenReturn( getImeInsets(new Rect(0, 0, 0, 0))); @@ -318,11 +320,11 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { mOverlayController.setClipData(mSampleClipData, ""); mCallbacks.onShareButtonTapped(); - verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()); - mAnimatorListenerCaptor.getValue().onAnimationEnd(mAnimator); + verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture()); + mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator); verify(mUiEventLogger, times(1)).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, ""); - verify(mClipboardOverlayView, times(1)).getFadeOutAnimation(); + verify(mClipboardOverlayView, times(1)).getExitAnimation(); } @Test @@ -343,8 +345,8 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { initController(); mCallbacks.onDismissButtonTapped(); - verify(mAnimator).addListener(mAnimatorListenerCaptor.capture()); - mAnimatorListenerCaptor.getValue().onAnimationEnd(mAnimator); + verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture()); + mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator); // package name is null since we haven't actually set a source for this test verify(mUiEventLogger, times(1)).log(CLIPBOARD_OVERLAY_DISMISS_TAPPED, 0, null); @@ -403,14 +405,18 @@ public class ClipboardOverlayControllerTest extends SysuiTestCase { mOverlayController.setClipData(mSampleClipData, "first.package"); mCallbacks.onShareButtonTapped(); + verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture()); + mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator); mOverlayController.setClipData(mSampleClipData, "second.package"); mCallbacks.onShareButtonTapped(); + verify(mEndAnimator, times(2)).addListener(mAnimatorListenerCaptor.capture()); + mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator); - verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "first.package"); - verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "second.package"); verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHOWN_EXPANDED, 0, "first.package"); + verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "first.package"); verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHOWN_EXPANDED, 0, "second.package"); + verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "second.package"); verifyNoMoreInteractions(mUiEventLogger); } diff --git a/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.kt new file mode 100644 index 000000000000..040a9e959094 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.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. + */ + +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.shared.clocks.FontTextStyle +import com.android.systemui.shared.clocks.LogUtil +import com.android.systemui.shared.clocks.view.SimpleDigitalClockTextView +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class SimpleDigitalClockTextViewTest : SysuiTestCase() { + private val messageBuffer = LogUtil.DEBUG_MESSAGE_BUFFER + private lateinit var underTest: SimpleDigitalClockTextView + private val defaultLargeClockTextSize = 500F + private val smallerTextSize = 300F + private val largerTextSize = 800F + private val firstMeasureTextSize = 100F + + @Before + fun setup() { + underTest = SimpleDigitalClockTextView(context, messageBuffer) + underTest.textStyle = FontTextStyle() + underTest.aodStyle = FontTextStyle() + underTest.text = "0" + underTest.applyTextSize(defaultLargeClockTextSize) + } + + @Test + fun applySmallerConstrainedTextSize_applyConstrainedTextSize() { + underTest.applyTextSize(smallerTextSize, constrainedByHeight = true) + assertEquals(smallerTextSize, underTest.textSize * underTest.fontSizeAdjustFactor) + } + + @Test + fun applyLargerConstrainedTextSize_applyUnconstrainedTextSize() { + underTest.applyTextSize(largerTextSize, constrainedByHeight = true) + assertEquals(defaultLargeClockTextSize, underTest.textSize) + } + + @Test + fun applyFirstMeasureConstrainedTextSize_getConstrainedTextSize() { + underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true) + underTest.applyTextSize(smallerTextSize, constrainedByHeight = true) + assertEquals(smallerTextSize, underTest.textSize * underTest.fontSizeAdjustFactor) + } + + @Test + fun applySmallFirstMeasureConstrainedSizeAndLargerConstrainedTextSize_applyDefaultSize() { + underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true) + underTest.applyTextSize(largerTextSize, constrainedByHeight = true) + assertEquals(defaultLargeClockTextSize, underTest.textSize) + } + + @Test + fun applyFirstMeasureConstrainedTextSize_applyUnconstrainedTextSize() { + underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true) + underTest.applyTextSize(defaultLargeClockTextSize) + assertEquals(defaultLargeClockTextSize, underTest.textSize) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java b/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java index f62beeb16ae5..beba0f0c6286 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java @@ -133,10 +133,6 @@ public class VolumeDialogControllerImplTest extends SysuiTestCase { when(mRingerModeInternalLiveData.getValue()).thenReturn(-1); when(mUserTracker.getUserId()).thenReturn(ActivityManager.getCurrentUser()); when(mUserTracker.getUserContext()).thenReturn(mContext); - // Enable group volume adjustments - mContext.getOrCreateTestableResources().addOverride( - com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions, - true); mCallback = mock(VolumeDialogControllerImpl.C.class); mThreadFactory.setLooper(TestableLooper.get(this).getLooper()); diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt index b37cac1d36fd..ba316835f2b3 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt @@ -19,8 +19,10 @@ package com.android.systemui.keyboard.data.repository import com.android.systemui.keyboard.data.model.Keyboard import com.android.systemui.keyboard.shared.model.BacklightModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.filterNotNull class FakeKeyboardRepository : KeyboardRepository { @@ -32,8 +34,14 @@ class FakeKeyboardRepository : KeyboardRepository { // filtering to make sure backlight doesn't have default initial value override val backlight: Flow<BacklightModel> = _backlightState.filterNotNull() - private val _newlyConnectedKeyboard: MutableStateFlow<Keyboard?> = MutableStateFlow(null) - override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.filterNotNull() + // implemented as channel because original implementation is modeling events: it doesn't hold + // state so it won't always emit once connected. And it's bad if some tests depend on that + // incorrect behaviour. + private val _newlyConnectedKeyboard: Channel<Keyboard> = Channel() + override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.consumeAsFlow() + + private val _connectedKeyboards: MutableStateFlow<Set<Keyboard>> = MutableStateFlow(setOf()) + override val connectedKeyboards: Flow<Set<Keyboard>> = _connectedKeyboards fun setBacklight(state: BacklightModel) { _backlightState.value = state @@ -43,7 +51,14 @@ class FakeKeyboardRepository : KeyboardRepository { _isAnyKeyboardConnected.value = connected } + fun setConnectedKeyboards(keyboards: Set<Keyboard>) { + _connectedKeyboards.value = keyboards + _isAnyKeyboardConnected.value = keyboards.isNotEmpty() + } + fun setNewlyConnectedKeyboard(keyboard: Keyboard) { - _newlyConnectedKeyboard.value = keyboard + _newlyConnectedKeyboard.trySend(keyboard) + _connectedKeyboards.value += keyboard + _isAnyKeyboardConnected.value = true } } diff --git a/services/core/java/com/android/server/media/MediaSessionRecord.java b/services/core/java/com/android/server/media/MediaSessionRecord.java index 0a9109b3e98c..d752429e64f7 100644 --- a/services/core/java/com/android/server/media/MediaSessionRecord.java +++ b/services/core/java/com/android/server/media/MediaSessionRecord.java @@ -16,7 +16,6 @@ package com.android.server.media; -import static android.media.MediaRoute2Info.PLAYBACK_VOLUME_FIXED; import static android.media.VolumeProvider.VOLUME_CONTROL_ABSOLUTE; import static android.media.VolumeProvider.VOLUME_CONTROL_FIXED; import static android.media.VolumeProvider.VOLUME_CONTROL_RELATIVE; @@ -48,9 +47,7 @@ import android.media.AudioAttributes; import android.media.AudioManager; import android.media.AudioSystem; import android.media.MediaMetadata; -import android.media.MediaRouter2Manager; import android.media.Rating; -import android.media.RoutingSessionInfo; import android.media.VolumeProvider; import android.media.session.ISession; import android.media.session.ISessionCallback; @@ -186,7 +183,6 @@ public class MediaSessionRecord extends MediaSessionRecordImpl implements IBinde private final MediaSessionService mService; private final UriGrantsManagerInternal mUgmInternal; private final Context mContext; - private final boolean mVolumeAdjustmentForRemoteGroupSessions; private final ForegroundServiceDelegationOptions mForegroundServiceDelegationOptions; @@ -311,8 +307,6 @@ public class MediaSessionRecord extends MediaSessionRecordImpl implements IBinde mAudioAttrs = DEFAULT_ATTRIBUTES; mPolicies = policies; mUgmInternal = LocalServices.getService(UriGrantsManagerInternal.class); - mVolumeAdjustmentForRemoteGroupSessions = mContext.getResources().getBoolean( - com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions); mForegroundServiceDelegationOptions = createForegroundServiceDelegationOptions(); @@ -659,49 +653,7 @@ public class MediaSessionRecord extends MediaSessionRecordImpl implements IBinde } return false; } - if (mVolumeAdjustmentForRemoteGroupSessions) { - if (DEBUG) { - Slog.d( - TAG, - "Volume adjustment for remote group sessions allowed so MediaSessionRecord" - + " can handle volume key"); - } - return true; - } - // See b/228021646 for details. - MediaRouter2Manager mRouter2Manager = MediaRouter2Manager.getInstance(mContext); - List<RoutingSessionInfo> sessions = mRouter2Manager.getRoutingSessions(mPackageName); - boolean foundNonSystemSession = false; - boolean remoteSessionAllowVolumeAdjustment = true; - if (DEBUG) { - Slog.d( - TAG, - "Found " - + sessions.size() - + " routing sessions for package name " - + mPackageName); - } - for (RoutingSessionInfo session : sessions) { - if (DEBUG) { - Slog.d(TAG, "Found routingSessionInfo: " + session); - } - if (!session.isSystemSession()) { - foundNonSystemSession = true; - if (session.getVolumeHandling() == PLAYBACK_VOLUME_FIXED) { - remoteSessionAllowVolumeAdjustment = false; - } - } - } - if (!foundNonSystemSession) { - if (DEBUG) { - Slog.d( - TAG, - "Package " + mPackageName - + " has a remote media session but no associated routing session"); - } - } - - return foundNonSystemSession && remoteSessionAllowVolumeAdjustment; + return true; } @Override |