diff --git a/Settings/AndroidManifest.xml b/Settings/AndroidManifest.xml
index aed51f3e..382c4c86 100644
--- a/Settings/AndroidManifest.xml
+++ b/Settings/AndroidManifest.xml
@@ -806,7 +806,7 @@
+ android:theme="@style/Theme.Material3.DayNight.BottomSheetDialog"/>
diff --git a/Settings/build.gradle b/Settings/build.gradle
index b24a7103..30c98931 100644
--- a/Settings/build.gradle
+++ b/Settings/build.gradle
@@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.atomicfu)
+ alias(libs.plugins.protobuf)
}
//preBuild {
@@ -45,6 +47,7 @@ android {
manifest.srcFile 'AndroidManifest.xml'
java.srcDirs = ['src']
res.srcDirs = ['res', 'res-export', 'res-product']
+ proto.srcDirs = ['protos']
}
buildTypes {
@@ -60,17 +63,26 @@ android {
kotlinOptions {
jvmTarget = '17'
}
+
+ buildFeatures {
+ compose true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = '1.5.0' // 假设1.4.0支持Kotlin 1.9.0
+ }
}
dependencies {
+ compileOnly files('../libs/framework.jar')
implementation libs.navigation.fragment.ktx
implementation libs.window.window.java
implementation libs.gson
implementation libs.guava
implementation files('../libs/BiometricsSharedLib.jar')
+ implementation files('../libs/BiometricsSharedLib-java.jar')
implementation files('../libs/SystemUIUnfoldLib.jar')
- implementation project(':WifiTrackerLib')
implementation files('../libs/android.hardware.dumpstate-V1-java.jar')
implementation files('../libs/android.hardware.dumpstate-V1.0-java.jar')
implementation files('../libs/android.hardware.dumpstate-V1.1-java.jar')
@@ -78,12 +90,55 @@ dependencies {
implementation files('../libs/net-utils-framework-common.jar')
implementation files('../libs/notification_flags_lib.jar')
implementation files('../libs/securebox.jar')
+
+ implementation files('../libs/FingerprintManagerInteractor.jar')
+ implementation files('../libs/MediaDrmSettingsFlagsLib.jar')
+ implementation files('../libs/Settings-change-ids.jar')
+ // SettingsLib的依赖在后面整体配置
+ implementation files('../libs/aconfig_settings_flags_lib.jar')
+ implementation files('../libs/accessibility_settings_flags_lib.jar')
+ implementation files('../libs/app-usage-event-protos-lite.jar')
+ implementation files('../libs/battery-event-protos-lite.jar')
+ implementation files('../libs/battery-usage-slot-protos-lite.jar')
implementation files('./libs/contextualcards.aar')
+ implementation files('../libs/development_settings_flag_lib.jar')
+ implementation files('../libs/factory_reset_flags_lib.jar')
+ implementation files('../libs/fuelgauge-log-protos-lite.jar')
+ implementation files('../libs/fuelgauge-usage-state-protos-lite.jar')
+ implementation files('../libs/power-anomaly-event-protos-lite.jar')
+ implementation files('../libs/settings-contextual-card-protos-lite.jar')
+ implementation files('../libs/settings-log-bridge-protos-lite.jar')
+ implementation files('../libs/settings-logtags.jar')
+ implementation files('../libs/settings-telephony-protos-lite.jar')
+ implementation files('../libs/setupdesign-lottie-loading-layout.jar')
+
+ implementation files('../libs/statslog-settings.jar')
+ implementation files('../libs/settingslib_flags_lib.jar')
+ implementation files('../libs/wifi_aconfig_flags_lib.jar')
+ implementation files('../libs/android.view.accessibility.flags-aconfig-java.jar')
+ implementation files('../libs/telephony_flags_core_java_lib.jar')
+
+
+// implementation files('../libs/SystemUISharedLib.jar')
+ implementation libs.androidx.appcompat
+ implementation libs.material
+ implementation libs.slice.builders
+ implementation libs.slice.core
+ implementation libs.slice.view
+ implementation libs.lottie
+// implementation libs.compose.ui
+ implementation libs.compose.material3
+ implementation libs.material.icons.core
+ implementation libs.preference
+ implementation libs.room.runtime
+ annotationProcessor libs.room.compiler
+ implementation libs.atomicfu
implementation(project(':SettingsLib'))
implementation(project(':SettingsLib:search'))
implementation project(':SettingsLib:ActionBarShadow')
implementation project(':SettingsLib:ActionButtonsPreference')
+ implementation project(':SettingsLib:ActivityEmbedding')
implementation project(':SettingsLib:AdaptiveIcon')
implementation project(':SettingsLib:AppPreference')
implementation project(':SettingsLib:BannerMessagePreference')
@@ -91,6 +146,7 @@ dependencies {
implementation project(':SettingsLib:ButtonPreference')
implementation project(':SettingsLib:CollapsingToolbarBaseActivity')
implementation project(':SettingsLib:Color')
+ implementation project(':SettingsLib:DataStore')
implementation project(':SettingsLib:DeviceStateRotationLock')
implementation project(':SettingsLib:DisplayUtils')
implementation project(':SettingsLib:EmergencyNumber')
@@ -113,4 +169,40 @@ dependencies {
implementation project(':SettingsLib:TwoTargetPreference')
implementation project(':SettingsLib:UsageProgressBarPreference')
implementation project(':SettingsLib:Utils')
+
+ implementation project(':WifiTrackerLib')
+ implementation project(':setupdesign')
+ implementation project(':setupcompat')
+ implementation project(':iconloaderlib')
+// implementation project(':lottie_loading_layout')
+ implementation project(':spa')
+ implementation project(':SettingsLib:SpaPrivileged')
+
+ implementation libs.protobuf.javalite
+ implementation libs.protoc
+ implementation libs.zxing
+ implementation files('../libs/settingslib_media_flags_lib.jar')
+ implementation files('../libs/core-all.jar')
+ implementation libs.dagger
+ annotationProcessor libs.dagger.compiler
}
+protobuf {
+ protoc {
+ // 通用 artifact
+ artifact = 'com.google.protobuf:protoc:3.8.0'
+ // MacOS
+ if (org.gradle.internal.os.OperatingSystem.current().isMacOsX()) {
+ artifact = 'com.google.protobuf:protoc:3.8.0:osx-x86_64'
+ }
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ remove java
+ java {
+ option "lite"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Settings/res/color/dream_card_color_state_list.xml b/Settings/res/color/dream_card_color_state_list.xml
index 082408d1..fdac2d24 100644
--- a/Settings/res/color/dream_card_color_state_list.xml
+++ b/Settings/res/color/dream_card_color_state_list.xml
@@ -17,6 +17,6 @@
-
-
+
+
\ No newline at end of file
diff --git a/Settings/res/color/dream_card_icon_color_state_list.xml b/Settings/res/color/dream_card_icon_color_state_list.xml
index ed34ae39..3579b634 100644
--- a/Settings/res/color/dream_card_icon_color_state_list.xml
+++ b/Settings/res/color/dream_card_icon_color_state_list.xml
@@ -17,6 +17,6 @@
-
-
+
+
\ No newline at end of file
diff --git a/Settings/res/color/dream_card_summary_color_state_list.xml b/Settings/res/color/dream_card_summary_color_state_list.xml
index a1845f44..0ebf7a7b 100644
--- a/Settings/res/color/dream_card_summary_color_state_list.xml
+++ b/Settings/res/color/dream_card_summary_color_state_list.xml
@@ -17,6 +17,6 @@
-
-
+
+
\ No newline at end of file
diff --git a/Settings/res/color/dream_card_text_color_state_list.xml b/Settings/res/color/dream_card_text_color_state_list.xml
index b39bbed7..eeb1fa7d 100644
--- a/Settings/res/color/dream_card_text_color_state_list.xml
+++ b/Settings/res/color/dream_card_text_color_state_list.xml
@@ -17,6 +17,6 @@
-
-
+
+
\ No newline at end of file
diff --git a/Settings/res/drawable/accessibility_contrast_button_background.xml b/Settings/res/drawable/accessibility_contrast_button_background.xml
index 281fcef2..f5cafd0c 100644
--- a/Settings/res/drawable/accessibility_contrast_button_background.xml
+++ b/Settings/res/drawable/accessibility_contrast_button_background.xml
@@ -22,9 +22,9 @@
-
-
+
@@ -41,7 +41,7 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
@@ -55,7 +55,7 @@
android:left="@dimen/contrast_button_stroke_width"
android:right="@dimen/contrast_button_stroke_width">
-
+
diff --git a/Settings/res/drawable/action_button_bg.xml b/Settings/res/drawable/action_button_bg.xml
index b50cc414..51e00fe1 100644
--- a/Settings/res/drawable/action_button_bg.xml
+++ b/Settings/res/drawable/action_button_bg.xml
@@ -26,7 +26,7 @@
+ android:color="?attr/colorAccentPrimaryVariant"/>
diff --git a/Settings/res/drawable/battery_hints_chip_bg.xml b/Settings/res/drawable/battery_hints_chip_bg.xml
index c2b662c2..48ac88b0 100644
--- a/Settings/res/drawable/battery_hints_chip_bg.xml
+++ b/Settings/res/drawable/battery_hints_chip_bg.xml
@@ -18,6 +18,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/broadcast_button_outline.xml b/Settings/res/drawable/broadcast_button_outline.xml
index c8658a9f..ad96e4b3 100644
--- a/Settings/res/drawable/broadcast_button_outline.xml
+++ b/Settings/res/drawable/broadcast_button_outline.xml
@@ -29,7 +29,7 @@
-
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_bottom_appbar_background.xml b/Settings/res/drawable/color_contrast_preview_bottom_appbar_background.xml
index f3392fb0..93801012 100644
--- a/Settings/res/drawable/color_contrast_preview_bottom_appbar_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_bottom_appbar_background.xml
@@ -17,7 +17,7 @@
-
+
-
+
diff --git a/Settings/res/drawable/color_contrast_preview_dialog_background.xml b/Settings/res/drawable/color_contrast_preview_dialog_background.xml
index f60a271e..5151df19 100644
--- a/Settings/res/drawable/color_contrast_preview_dialog_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_dialog_background.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_icon_edit_background.xml b/Settings/res/drawable/color_contrast_preview_icon_edit_background.xml
index 14c5f3c9..317dfc9b 100644
--- a/Settings/res/drawable/color_contrast_preview_icon_edit_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_icon_edit_background.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_icon_group_background.xml b/Settings/res/drawable/color_contrast_preview_icon_group_background.xml
index b8554c19..1f42b9e7 100644
--- a/Settings/res/drawable/color_contrast_preview_icon_group_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_icon_group_background.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_icon_inbox_background.xml b/Settings/res/drawable/color_contrast_preview_icon_inbox_background.xml
index 45d82852..a72a02ae 100644
--- a/Settings/res/drawable/color_contrast_preview_icon_inbox_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_icon_inbox_background.xml
@@ -17,7 +17,7 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_icon_star_background.xml b/Settings/res/drawable/color_contrast_preview_icon_star_background.xml
index 335ee886..2d4551df 100644
--- a/Settings/res/drawable/color_contrast_preview_icon_star_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_icon_star_background.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/color_contrast_preview_tag_background.xml b/Settings/res/drawable/color_contrast_preview_tag_background.xml
index a7b051aa..58fb31bc 100644
--- a/Settings/res/drawable/color_contrast_preview_tag_background.xml
+++ b/Settings/res/drawable/color_contrast_preview_tag_background.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/dream_default_preview_icon.xml b/Settings/res/drawable/dream_default_preview_icon.xml
index 8989929f..f2cdbbe2 100644
--- a/Settings/res/drawable/dream_default_preview_icon.xml
+++ b/Settings/res/drawable/dream_default_preview_icon.xml
@@ -20,6 +20,6 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
-
\ No newline at end of file
diff --git a/Settings/res/drawable/dream_preview_rounded_bg.xml b/Settings/res/drawable/dream_preview_rounded_bg.xml
index 7cae599b..477ffb6f 100644
--- a/Settings/res/drawable/dream_preview_rounded_bg.xml
+++ b/Settings/res/drawable/dream_preview_rounded_bg.xml
@@ -17,6 +17,6 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/drawable/ic_article_24dp.xml b/Settings/res/drawable/ic_article_24dp.xml
index 0b38daaf..e73d6313 100644
--- a/Settings/res/drawable/ic_article_24dp.xml
+++ b/Settings/res/drawable/ic_article_24dp.xml
@@ -22,6 +22,6 @@
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
diff --git a/Settings/res/drawable/ic_article_filled_24dp.xml b/Settings/res/drawable/ic_article_filled_24dp.xml
index e22d151b..2344aa9c 100644
--- a/Settings/res/drawable/ic_article_filled_24dp.xml
+++ b/Settings/res/drawable/ic_article_filled_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/ic_chat_bubble_24dp.xml b/Settings/res/drawable/ic_chat_bubble_24dp.xml
index c7ad6bf2..4b72af9a 100644
--- a/Settings/res/drawable/ic_chat_bubble_24dp.xml
+++ b/Settings/res/drawable/ic_chat_bubble_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/ic_check_24dp.xml b/Settings/res/drawable/ic_check_24dp.xml
index 0ed6b325..99b2bbb3 100644
--- a/Settings/res/drawable/ic_check_24dp.xml
+++ b/Settings/res/drawable/ic_check_24dp.xml
@@ -18,7 +18,7 @@
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
- android:tint="?androidprv:attr/colorAccent">
+ android:tint="?attr/colorAccent">
diff --git a/Settings/res/drawable/ic_edit_24dp.xml b/Settings/res/drawable/ic_edit_24dp.xml
index c9dbfc33..20269202 100644
--- a/Settings/res/drawable/ic_edit_24dp.xml
+++ b/Settings/res/drawable/ic_edit_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/ic_group_24dp.xml b/Settings/res/drawable/ic_group_24dp.xml
index 92815c2c..32091044 100644
--- a/Settings/res/drawable/ic_group_24dp.xml
+++ b/Settings/res/drawable/ic_group_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/ic_inbox_24dp.xml b/Settings/res/drawable/ic_inbox_24dp.xml
index 7800ea14..921402e0 100644
--- a/Settings/res/drawable/ic_inbox_24dp.xml
+++ b/Settings/res/drawable/ic_inbox_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/ic_modifier_keys_reset.xml b/Settings/res/drawable/ic_modifier_keys_reset.xml
index 5345c257..8d845c3e 100644
--- a/Settings/res/drawable/ic_modifier_keys_reset.xml
+++ b/Settings/res/drawable/ic_modifier_keys_reset.xml
@@ -21,7 +21,7 @@
android:height="24.0dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
- android:tint="?androidprv:attr/materialColorPrimary">
+ android:tint="?attr/materialColorPrimary">
diff --git a/Settings/res/drawable/ic_star_24dp.xml b/Settings/res/drawable/ic_star_24dp.xml
index 38535e6c..97e538e4 100644
--- a/Settings/res/drawable/ic_star_24dp.xml
+++ b/Settings/res/drawable/ic_star_24dp.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/Settings/res/drawable/modifier_key_bordered.xml b/Settings/res/drawable/modifier_key_bordered.xml
index 29316796..fc569623 100644
--- a/Settings/res/drawable/modifier_key_bordered.xml
+++ b/Settings/res/drawable/modifier_key_bordered.xml
@@ -25,7 +25,7 @@
+ android:color="?attr/materialColorPrimary"/>
diff --git a/Settings/res/drawable/modifier_key_colored.xml b/Settings/res/drawable/modifier_key_colored.xml
index 995d7336..f76feb65 100644
--- a/Settings/res/drawable/modifier_key_colored.xml
+++ b/Settings/res/drawable/modifier_key_colored.xml
@@ -22,7 +22,7 @@
-
-
+
diff --git a/Settings/res/drawable/modifier_key_lisetview_background.xml b/Settings/res/drawable/modifier_key_lisetview_background.xml
index 3353cfda..26e4d523 100644
--- a/Settings/res/drawable/modifier_key_lisetview_background.xml
+++ b/Settings/res/drawable/modifier_key_lisetview_background.xml
@@ -22,7 +22,7 @@
-
-
+
diff --git a/Settings/res/drawable/rounded_bg.xml b/Settings/res/drawable/rounded_bg.xml
index ae12af89..41b5c039 100644
--- a/Settings/res/drawable/rounded_bg.xml
+++ b/Settings/res/drawable/rounded_bg.xml
@@ -17,7 +17,7 @@
-
+
-
+ android:color="?attr/materialColorPrimary"/>
diff --git a/Settings/res/drawable/trackpad_button_done_colored.xml b/Settings/res/drawable/trackpad_button_done_colored.xml
index 995d7336..f76feb65 100644
--- a/Settings/res/drawable/trackpad_button_done_colored.xml
+++ b/Settings/res/drawable/trackpad_button_done_colored.xml
@@ -22,7 +22,7 @@
-
-
+
diff --git a/Settings/res/drawable/user_select_background.xml b/Settings/res/drawable/user_select_background.xml
index 7b751602..819efe2c 100644
--- a/Settings/res/drawable/user_select_background.xml
+++ b/Settings/res/drawable/user_select_background.xml
@@ -19,13 +19,13 @@
android:color="@color/settingslib_ripple_color">
-
-
+
-
-
+
diff --git a/Settings/res/drawable/volume_dialog_button_background_outline.xml b/Settings/res/drawable/volume_dialog_button_background_outline.xml
index 36ffb93f..94fca464 100644
--- a/Settings/res/drawable/volume_dialog_button_background_outline.xml
+++ b/Settings/res/drawable/volume_dialog_button_background_outline.xml
@@ -21,7 +21,7 @@
-
+
\ No newline at end of file
diff --git a/Settings/res/layout/accessibility_color_contrast_preview.xml b/Settings/res/layout/accessibility_color_contrast_preview.xml
index 2646709d..edfd2991 100644
--- a/Settings/res/layout/accessibility_color_contrast_preview.xml
+++ b/Settings/res/layout/accessibility_color_contrast_preview.xml
@@ -28,7 +28,7 @@
+ android:textColor="?attr/materialColorOnSurface"/>
+ android:textColor="?attr/materialColorOnSurface"/>
+ android:textColor="?attr/materialColorOnSurface"/>
+ android:textColor="?attr/materialColorOnSurfaceVariant"/>
+ android:tint="?attr/materialColorPrimary"/>
diff --git a/Settings/res/layout/power_anomaly_hints.xml b/Settings/res/layout/power_anomaly_hints.xml
index 3781046e..4df04218 100644
--- a/Settings/res/layout/power_anomaly_hints.xml
+++ b/Settings/res/layout/power_anomaly_hints.xml
@@ -38,6 +38,6 @@
android:paddingHorizontal="8dp"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
- android:textColor="?androidprv:attr/materialColorOnSurface"/>
+ android:textColor="?attr/materialColorOnSurface"/>
\ No newline at end of file
diff --git a/Settings/res/layout/private_space_confirm_deletion.xml b/Settings/res/layout/private_space_confirm_deletion.xml
index 31418e1d..e3abeb75 100644
--- a/Settings/res/layout/private_space_confirm_deletion.xml
+++ b/Settings/res/layout/private_space_confirm_deletion.xml
@@ -14,17 +14,20 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
-
-
-
\ No newline at end of file
+ android:layout_height="match_parent">
+
+
+
\ No newline at end of file
diff --git a/Settings/res/layout/trackpad_gesture_preview.xml b/Settings/res/layout/trackpad_gesture_preview.xml
index 15cc7431..54080f6a 100644
--- a/Settings/res/layout/trackpad_gesture_preview.xml
+++ b/Settings/res/layout/trackpad_gesture_preview.xml
@@ -82,7 +82,7 @@
android:paddingVertical="14dp"
android:drawablePadding="9dp"
style="@style/TrackpadButtonDone"
- android:textColor="?androidprv:attr/materialColorOnPrimary"
+ android:textColor="?attr/materialColorOnPrimary"
android:text="@string/gesture_button_next"/>
diff --git a/Settings/res/values-night/colors.xml b/Settings/res/values-night/colors.xml
index a572841b..674c5d83 100644
--- a/Settings/res/values-night/colors.xml
+++ b/Settings/res/values-night/colors.xml
@@ -19,7 +19,7 @@
#783BE5
#3F5FBD
@*android:color/material_grey_900
- ?androidprv:attr/materialColorSurfaceContainerHigh
+ ?attr/materialColorSurfaceContainerHigh
#5F6368
diff --git a/Settings/res/values/attrs.xml b/Settings/res/values/attrs.xml
index 200253ac..766671d7 100644
--- a/Settings/res/values/attrs.xml
+++ b/Settings/res/values/attrs.xml
@@ -203,4 +203,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Settings/res/values/colors.xml b/Settings/res/values/colors.xml
index 03225a5c..d0b4c201 100644
--- a/Settings/res/values/colors.xml
+++ b/Settings/res/values/colors.xml
@@ -94,7 +94,7 @@
@*android:color/accent_device_default_light
- ?androidprv:attr/materialColorSurfaceBright
+ ?attr/materialColorSurfaceBright
#42a5f5
@@ -166,7 +166,7 @@
@*android:color/primary_text_default_material_light
- ?androidprv:attr/materialColorSurfaceContainer
+ ?attr/materialColorSurfaceContainer
?android:attr/textColorPrimary
diff --git a/Settings/res/values/strings.xml b/Settings/res/values/strings.xml
index ef93ba71..7a6195cd 100644
--- a/Settings/res/values/strings.xml
+++ b/Settings/res/values/strings.xml
@@ -13,7 +13,9 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
+
"Yes"
diff --git a/Settings/res/values/styles.xml b/Settings/res/values/styles.xml
index 0a28b016..d58177fb 100644
--- a/Settings/res/values/styles.xml
+++ b/Settings/res/values/styles.xml
@@ -448,7 +448,7 @@
- @dimen/contextual_card_vertical_margin
- @dimen/contextual_card_side_margin
- @dimen/contextual_card_side_margin
- - ?androidprv:attr/materialColorSurfaceContainer
+ - ?attr/materialColorSurfaceContainer
- @dimen/contextual_card_corner_radius
- 0dp
- ?android:attr/colorControlHighlight
diff --git a/Settings/res/values/themes.xml b/Settings/res/values/themes.xml
index b149bb8f..a474e609 100644
--- a/Settings/res/values/themes.xml
+++ b/Settings/res/values/themes.xml
@@ -67,12 +67,12 @@
- @style/Widget.SliceView.Settings
- @android:color/transparent
- - ?androidprv:attr/materialColorOutlineVariant
- - ?androidprv:attr/materialColorOnSurfaceVariant
+ - ?attr/materialColorOutlineVariant
+ - ?attr/materialColorOnSurfaceVariant
- - ?androidprv:attr/materialColorSecondaryContainer
- - ?androidprv:attr/materialColorOnSecondaryContainer
- - ?androidprv:attr/materialColorOnSecondaryContainer
+ - ?attr/materialColorSecondaryContainer
+ - ?attr/materialColorOnSecondaryContainer
+ - ?attr/materialColorOnSecondaryContainer
diff --git a/Settings/src/com/android/settings/development/graphicsdriver/GraphicsDriverEnableForAllAppsPreferenceController.java b/Settings/src/com/android/settings/development/graphicsdriver/GraphicsDriverEnableForAllAppsPreferenceController.java
index 5106a78f..c5bf79f8 100644
--- a/Settings/src/com/android/settings/development/graphicsdriver/GraphicsDriverEnableForAllAppsPreferenceController.java
+++ b/Settings/src/com/android/settings/development/graphicsdriver/GraphicsDriverEnableForAllAppsPreferenceController.java
@@ -40,7 +40,7 @@ import com.android.settingslib.core.lifecycle.events.OnStart;
import com.android.settingslib.core.lifecycle.events.OnStop;
import com.android.settingslib.development.DevelopmentSettingsEnabler;
-import dalvik.system.VMRuntime;
+//import dalvik.system.VMRuntime;
import java.util.ArrayList;
import java.util.List;
@@ -218,15 +218,15 @@ public class GraphicsDriverEnableForAllAppsPreferenceController extends BasePref
}
private static String chooseAbi(ApplicationInfo ai) {
- final String isa = VMRuntime.getCurrentInstructionSet();
- if (ai.primaryCpuAbi != null
- && isa.equals(VMRuntime.getInstructionSet(ai.primaryCpuAbi))) {
- return ai.primaryCpuAbi;
- }
- if (ai.secondaryCpuAbi != null
- && isa.equals(VMRuntime.getInstructionSet(ai.secondaryCpuAbi))) {
- return ai.secondaryCpuAbi;
- }
+// final String isa = VMRuntime.getCurrentInstructionSet();
+// if (ai.primaryCpuAbi != null
+// && isa.equals(VMRuntime.getInstructionSet(ai.primaryCpuAbi))) {
+// return ai.primaryCpuAbi;
+// }
+// if (ai.secondaryCpuAbi != null
+// && isa.equals(VMRuntime.getInstructionSet(ai.secondaryCpuAbi))) {
+// return ai.secondaryCpuAbi;
+// }
return null;
}
}
diff --git a/Settings/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java b/Settings/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java
index b67c8483..d681fef3 100644
--- a/Settings/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java
+++ b/Settings/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java
@@ -56,6 +56,7 @@ import android.util.Log;
import android.widget.ImageView;
import android.widget.Toast;
+import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.text.BidiFormatter;
import androidx.preference.Preference;
@@ -80,6 +81,7 @@ import com.android.settingslib.core.lifecycle.Lifecycle;
import com.android.settingslib.core.lifecycle.LifecycleObserver;
import com.android.settingslib.core.lifecycle.events.OnPause;
import com.android.settingslib.core.lifecycle.events.OnResume;
+import com.android.settingslib.search.SearchIndexableRaw;
import com.android.settingslib.utils.StringUtil;
import com.android.settingslib.widget.ActionButtonsPreference;
import com.android.settingslib.widget.LayoutPreference;
@@ -194,6 +196,31 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle
private static final int TOKEN_QUERY_CARRIER_ID_AND_UPDATE_SIM_SUMMARY = 1;
private static final int COLUMN_CARRIER_NAME = 0;
+ @Override
+ public void updateNonIndexableKeys(List keys) {
+ PreferenceControllerMixin.super.updateNonIndexableKeys(keys);
+ }
+
+ @Override
+ public void updateRawDataToIndex(List rawData) {
+ PreferenceControllerMixin.super.updateRawDataToIndex(rawData);
+ }
+
+ @Override
+ public void updateDynamicRawDataToIndex(List rawData) {
+ PreferenceControllerMixin.super.updateDynamicRawDataToIndex(rawData);
+ }
+
+ @Override
+ public void onForget(@NonNull WifiDialog2 dialog) {
+ WifiDialog2Listener.super.onForget(dialog);
+ }
+
+ @Override
+ public void onScan(@NonNull WifiDialog2 dialog, @NonNull String ssid) {
+ WifiDialog2Listener.super.onScan(dialog, ssid);
+ }
+
private class CarrierIdAsyncQueryHandler extends AsyncQueryHandler {
private CarrierIdAsyncQueryHandler(Context context) {
diff --git a/SettingsLib/ActivityEmbedding/build.gradle b/SettingsLib/ActivityEmbedding/build.gradle
new file mode 100644
index 00000000..7803fbb3
--- /dev/null
+++ b/SettingsLib/ActivityEmbedding/build.gradle
@@ -0,0 +1,56 @@
+/**
+ * Include this gradle file if you are building against this as a standalone gradle library project,
+ * as opposed to building it as part of the git-tree. This is typically the file you want to include
+ * if you create a new project in Android Studio.
+ *
+ * For example, you can include the following in your settings.gradle file:
+ * include ':setupcompat'
+ * project(':setupcompat').projectDir = new File(PATH_TO_THIS_DIRECTORY)
+ *
+ * And then you can include the :setupcompat project as one of your dependencies
+ * dependencies {
+ * implementation project(path: ':setupcompat')
+ * }
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ // Not specifying compileSdkVersion here so clients can specify it; must be at least Q
+ namespace = "com.android.settingslib.widget.activityembedding"
+ compileSdk 34
+ defaultConfig {
+ minSdkVersion 31
+ targetSdkVersion 34
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.flags'
+ }
+ }
+
+ sourceSets.main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation libs.androidx.annotation.annotation
+ implementation libs.androidx.core.core
+ implementation libs.window.window
+ implementation project(':SettingsLib:Utils')
+
+ implementation libs.recyclerview
+}
\ No newline at end of file
diff --git a/SettingsLib/DataStore/build.gradle b/SettingsLib/DataStore/build.gradle
new file mode 100644
index 00000000..a6c50ef9
--- /dev/null
+++ b/SettingsLib/DataStore/build.gradle
@@ -0,0 +1,53 @@
+/**
+ * Include this gradle file if you are building against this as a standalone gradle library project,
+ * as opposed to building it as part of the git-tree. This is typically the file you want to include
+ * if you create a new project in Android Studio.
+ *
+ * For example, you can include the following in your settings.gradle file:
+ * include ':setupcompat'
+ * project(':setupcompat').projectDir = new File(PATH_TO_THIS_DIRECTORY)
+ *
+ * And then you can include the :setupcompat project as one of your dependencies
+ * dependencies {
+ * implementation project(path: ':setupcompat')
+ * }
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ // Not specifying compileSdkVersion here so clients can specify it; must be at least Q
+ namespace = "com.android.settingslib.datastore"
+ compileSdk 34
+ defaultConfig {
+ minSdkVersion 31
+ targetSdkVersion 34
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.flags'
+ }
+ }
+
+ sourceSets.main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation libs.androidx.annotation.annotation
+ implementation libs.collection.ktx
+ implementation libs.guava
+}
\ No newline at end of file
diff --git a/SettingsLib/SpaPrivileged/build.gradle b/SettingsLib/SpaPrivileged/build.gradle
new file mode 100644
index 00000000..3bb4f8f4
--- /dev/null
+++ b/SettingsLib/SpaPrivileged/build.gradle
@@ -0,0 +1,66 @@
+/**
+ * Include this gradle file if you are building against this as a standalone gradle library project,
+ * as opposed to building it as part of the git-tree. This is typically the file you want to include
+ * if you create a new project in Android Studio.
+ *
+ * For example, you can include the following in your settings.gradle file:
+ * include ':setupcompat'
+ * project(':setupcompat').projectDir = new File(PATH_TO_THIS_DIRECTORY)
+ *
+ * And then you can include the :setupcompat project as one of your dependencies
+ * dependencies {
+ * implementation project(path: ':setupcompat')
+ * }
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ // Not specifying compileSdkVersion here so clients can specify it; must be at least Q
+ namespace = "com.android.settingslib.spaprivileged"
+ compileSdk 34
+ defaultConfig {
+ minSdkVersion 31
+ targetSdkVersion 34
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.flags'
+ }
+ }
+
+ sourceSets.main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = '1.5.0' // 假设1.4.0支持Kotlin 1.9.0
+ }
+}
+
+dependencies {
+ implementation project(':spa')
+ implementation project(':SettingsLib')
+ implementation project(':SettingsLib:RestrictedLockUtils')
+ implementation project(':SettingsLib:AppPreference')
+ implementation libs.compose.ui
+ implementation "androidx.compose.material3:material3:1.3.0"
+ implementation "androidx.compose.material:material-icons-extended:1.5.0"
+ implementation "androidx.compose.runtime:runtime-livedata:1.5.0"
+ implementation "androidx.compose.ui:ui-tooling-preview:1.5.0"
+}
\ No newline at end of file
diff --git a/SettingsLib/build.gradle b/SettingsLib/build.gradle
index 76f907a2..ce8926f6 100644
--- a/SettingsLib/build.gradle
+++ b/SettingsLib/build.gradle
@@ -51,7 +51,7 @@ dependencies {
implementation libs.localbroadcastmanager
implementation libs.room.runtime
implementation libs.sqlite
- implementation files('../libs/zxing-core.jar')
+ implementation libs.zxing
// Android14\out\soong\.intermediates\external\guava\guava\android_common\turbine-combined
// implementation(files('../libs/guava.jar'))
implementation libs.guava
diff --git a/app/src/main/res/layout/private_space_confirm_deletion.xml b/app/src/main/res/layout/private_space_confirm_deletion.xml
new file mode 100644
index 00000000..7a0476de
--- /dev/null
+++ b/app/src/main/res/layout/private_space_confirm_deletion.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5417a3ca..7718e762 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,13 +11,13 @@ activity = "1.9.3"
constraintlayout = "2.2.0"
annotation = "1.8.0-alpha01"
lifecycle-runtime = "2.6.1"
-recyclerview = "1.0.0"
+recyclerview = "1.3.2"
room-runtime = "2.7.0-alpha01"
sqlite = "2.5.0-alpha01"
window = "1.3.0"
legacy-support-core-ui = "1.0.0"
preference = "1.2.1"
-lottie = "6.1.0"
+lottie = "6.5.0"
lifecycle-extensions = "2.2.0"
car-ui-lib = "2.6.0"
constraintlayout-solver = "2.0.4"
@@ -28,6 +28,14 @@ localbroadcastmanager = "1.0.0"
javapoet = "1.13.0"
navigation-fragment-ktx = "2.5.3"
gson = "2.10.1"
+slice_builders = "1.1.0-alpha02"
+compose-material3 = "1.3.0"
+atomicfu = "0.26.1"
+collection-ktx = "1.4.0"
+protobuf = "0.9.4"
+protobuf_javalite = "3.8.0"
+zxing = "3.5.3"
+dagger = "2.51"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -43,6 +51,7 @@ androidx-annotation-annotation = { group = "androidx.annotation", name = "annota
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycle-runtime" }
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room-runtime" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room-runtime" }
sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" }
window-window = {group = "androidx.window", name = "window", version.ref = "window"}
legacy-support-core-ui = { group = "androidx.legacy", name = "legacy-support-core-ui", version.ref = "legacy-support-core-ui" }
@@ -64,9 +73,26 @@ javapoet = {group = "com.squareup", name = "javapoet", version.ref = "javapoet"}
navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation-fragment-ktx" }
window-window-java = {group = "androidx.window", name = "window-java", version.ref = "window"}
gson = {group = "com.google.code.gson", name = "gson", version.ref = "gson"}
+slice_builders = {group = "androidx.slice", name = "slice-builders", version.ref = "slice_builders"}
+slice_core = {group = "androidx.slice", name = "slice-core", version.ref = "slice_builders"}
+slice_view = {group = "androidx.slice", name = "slice-view", version.ref = "slice_builders"}
+
+compose-ui = {group = "androidx.compose.ui", name= "ui", version.ref = "material"}
+compose-material3 = {group = "androidx.compose.material3", name= "material3", version.ref = "compose-material3"}
+material-icons-core = {group = "androidx.compose.material", name= "material-icons-core", version.ref = "material"}
+atomicfu = {group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicfu"}
+collection-ktx = {group = "androidx.collection", name = "collection-ktx", version.ref = "collection-ktx"}
+protobuf_javalite = {group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf_javalite"}
+protoc = {group = "com.google.protobuf", name = "protoc", version.ref = "protobuf_javalite"}
+zxing = {group = "com.google.zxing", name = "core", version.ref = "zxing"}
+dagger = {group = "com.google.dagger", name = "dagger", version.ref = "dagger"}
+dagger-compiler = {group = "com.google.dagger", name = "dagger-compiler", version.ref = "dagger"}
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
+atomicfu = {id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu"}
+protobuf = {id = "com.google.protobuf", version.ref = "protobuf"}
diff --git a/libs/BiometricsSharedLib-java.jar b/libs/BiometricsSharedLib-java.jar
new file mode 100644
index 00000000..ce39b340
Binary files /dev/null and b/libs/BiometricsSharedLib-java.jar differ
diff --git a/libs/BiometricsSharedLib-turbin.jar b/libs/BiometricsSharedLib-turbin.jar
new file mode 100644
index 00000000..47c84565
Binary files /dev/null and b/libs/BiometricsSharedLib-turbin.jar differ
diff --git a/libs/BiometricsSharedLib.jar b/libs/BiometricsSharedLib.jar
index ce39b340..16c68606 100644
Binary files a/libs/BiometricsSharedLib.jar and b/libs/BiometricsSharedLib.jar differ
diff --git a/libs/FingerprintManagerInteractor.jar b/libs/FingerprintManagerInteractor.jar
new file mode 100644
index 00000000..839ad96a
Binary files /dev/null and b/libs/FingerprintManagerInteractor.jar differ
diff --git a/libs/MediaDrmSettingsFlagsLib.jar b/libs/MediaDrmSettingsFlagsLib.jar
new file mode 100644
index 00000000..97fa29c4
Binary files /dev/null and b/libs/MediaDrmSettingsFlagsLib.jar differ
diff --git a/libs/Settings-change-ids.jar b/libs/Settings-change-ids.jar
new file mode 100644
index 00000000..7b1e25f0
Binary files /dev/null and b/libs/Settings-change-ids.jar differ
diff --git a/libs/SystemUISharedLib.jar b/libs/SystemUISharedLib.jar
new file mode 100644
index 00000000..cc0a0320
Binary files /dev/null and b/libs/SystemUISharedLib.jar differ
diff --git a/libs/SystemUIUnfoldLib.jar b/libs/SystemUIUnfoldLib.jar
index 5bd2b509..c4e35257 100644
Binary files a/libs/SystemUIUnfoldLib.jar and b/libs/SystemUIUnfoldLib.jar differ
diff --git a/libs/accessibility_settings_flags_lib.jar b/libs/accessibility_settings_flags_lib.jar
new file mode 100644
index 00000000..779d7f34
Binary files /dev/null and b/libs/accessibility_settings_flags_lib.jar differ
diff --git a/libs/aconfig_settings_flags_lib.jar b/libs/aconfig_settings_flags_lib.jar
new file mode 100644
index 00000000..975afca9
Binary files /dev/null and b/libs/aconfig_settings_flags_lib.jar differ
diff --git a/libs/android.view.accessibility.flags-aconfig-java.jar b/libs/android.view.accessibility.flags-aconfig-java.jar
new file mode 100644
index 00000000..c61c94aa
Binary files /dev/null and b/libs/android.view.accessibility.flags-aconfig-java.jar differ
diff --git a/libs/app-usage-event-protos-lite.jar b/libs/app-usage-event-protos-lite.jar
new file mode 100644
index 00000000..b476188c
Binary files /dev/null and b/libs/app-usage-event-protos-lite.jar differ
diff --git a/libs/battery-event-protos-lite.jar b/libs/battery-event-protos-lite.jar
new file mode 100644
index 00000000..4515bb05
Binary files /dev/null and b/libs/battery-event-protos-lite.jar differ
diff --git a/libs/battery-usage-slot-protos-lite.jar b/libs/battery-usage-slot-protos-lite.jar
new file mode 100644
index 00000000..cabfc90c
Binary files /dev/null and b/libs/battery-usage-slot-protos-lite.jar differ
diff --git a/libs/development_settings_flag_lib.jar b/libs/development_settings_flag_lib.jar
new file mode 100644
index 00000000..ca6f5f38
Binary files /dev/null and b/libs/development_settings_flag_lib.jar differ
diff --git a/libs/factory_reset_flags_lib.jar b/libs/factory_reset_flags_lib.jar
new file mode 100644
index 00000000..0937dad1
Binary files /dev/null and b/libs/factory_reset_flags_lib.jar differ
diff --git a/libs/fuelgauge-log-protos-lite.jar b/libs/fuelgauge-log-protos-lite.jar
new file mode 100644
index 00000000..1cc63d85
Binary files /dev/null and b/libs/fuelgauge-log-protos-lite.jar differ
diff --git a/libs/fuelgauge-usage-state-protos-lite.jar b/libs/fuelgauge-usage-state-protos-lite.jar
new file mode 100644
index 00000000..dba3ec23
Binary files /dev/null and b/libs/fuelgauge-usage-state-protos-lite.jar differ
diff --git a/libs/power-anomaly-event-protos-lite.jar b/libs/power-anomaly-event-protos-lite.jar
new file mode 100644
index 00000000..318ab8f5
Binary files /dev/null and b/libs/power-anomaly-event-protos-lite.jar differ
diff --git a/libs/settings-contextual-card-protos-lite.jar b/libs/settings-contextual-card-protos-lite.jar
new file mode 100644
index 00000000..788dcd52
Binary files /dev/null and b/libs/settings-contextual-card-protos-lite.jar differ
diff --git a/libs/settings-log-bridge-protos-lite.jar b/libs/settings-log-bridge-protos-lite.jar
new file mode 100644
index 00000000..1b6c7fb9
Binary files /dev/null and b/libs/settings-log-bridge-protos-lite.jar differ
diff --git a/libs/settings-logtags.jar b/libs/settings-logtags.jar
new file mode 100644
index 00000000..d7c82020
Binary files /dev/null and b/libs/settings-logtags.jar differ
diff --git a/libs/settings-telephony-protos-lite.jar b/libs/settings-telephony-protos-lite.jar
new file mode 100644
index 00000000..604926a2
Binary files /dev/null and b/libs/settings-telephony-protos-lite.jar differ
diff --git a/libs/setupdesign-lottie-loading-layout.jar b/libs/setupdesign-lottie-loading-layout.jar
new file mode 100644
index 00000000..836ecaa0
Binary files /dev/null and b/libs/setupdesign-lottie-loading-layout.jar differ
diff --git a/libs/statslog-settings.jar b/libs/statslog-settings.jar
new file mode 100644
index 00000000..154c9eb8
Binary files /dev/null and b/libs/statslog-settings.jar differ
diff --git a/libs/telephony_flags_core_java_lib.jar b/libs/telephony_flags_core_java_lib.jar
new file mode 100644
index 00000000..f96c227c
Binary files /dev/null and b/libs/telephony_flags_core_java_lib.jar differ
diff --git a/lottie_loading_layout/Android.bp b/lottie_loading_layout/Android.bp
new file mode 100644
index 00000000..6d418125
--- /dev/null
+++ b/lottie_loading_layout/Android.bp
@@ -0,0 +1,31 @@
+//
+// Build the setup design - lottie_loading_layout.
+//
+
+package {
+ // See: http://go/android-license-faq
+ // A large-scale-change added 'default_applicable_licenses' to import
+ // all of the 'license_kinds' from "external_setupdesign_license"
+ // to get the below license kinds:
+ // SPDX-license-identifier-Apache-2.0
+ default_applicable_licenses: ["external_setupdesign_license"],
+}
+
+android_library {
+ name: "setupdesign-lottie-loading-layout",
+ manifest: "AndroidManifest.xml",
+ static_libs: [
+ "androidx.annotation_annotation",
+ "lottie",
+ "setupcompat",
+ "setupdesign",
+ ],
+ srcs: [
+ "src/**/*.java",
+ ],
+ resource_dirs: [
+ "res",
+ ],
+ min_sdk_version: "16",
+ sdk_version: "current"
+}
diff --git a/lottie_loading_layout/AndroidManifest.xml b/lottie_loading_layout/AndroidManifest.xml
new file mode 100644
index 00000000..1b0b77e1
--- /dev/null
+++ b/lottie_loading_layout/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/build.gradle b/lottie_loading_layout/build.gradle
new file mode 100644
index 00000000..4bdfca85
--- /dev/null
+++ b/lottie_loading_layout/build.gradle
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "com.google.android.setupdesign.lottieloadinglayout"
+ compileSdk 34
+ defaultConfig {
+ minSdk 31
+ targetSdk 34
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs = ['src']
+ manifest.srcFile 'AndroidManifest.xml'
+ res.srcDirs = ['res']
+ }
+ }
+ lint {
+ abortOnError false
+ }
+
+ tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+// compileOnly files('../libs/framework.jar')
+ implementation libs.androidx.annotation.annotation
+ implementation libs.lottie
+ implementation project(':setupdesign')
+ implementation project(':setupcompat')
+}
diff --git a/lottie_loading_layout/res/layout-land-v31/sud_glif_loading_template_content.xml b/lottie_loading_layout/res/layout-land-v31/sud_glif_loading_template_content.xml
new file mode 100644
index 00000000..ef0ba718
--- /dev/null
+++ b/lottie_loading_layout/res/layout-land-v31/sud_glif_loading_template_content.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-sw600dp-land-v31/sud_glif_fullscreen_loading_template_content.xml b/lottie_loading_layout/res/layout-sw600dp-land-v31/sud_glif_fullscreen_loading_template_content.xml
new file mode 100644
index 00000000..e5e1bb50
--- /dev/null
+++ b/lottie_loading_layout/res/layout-sw600dp-land-v31/sud_glif_fullscreen_loading_template_content.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_card.xml b/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_card.xml
new file mode 100644
index 00000000..2aa254da
--- /dev/null
+++ b/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_card.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_content.xml b/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_content.xml
new file mode 100644
index 00000000..97f86bed
--- /dev/null
+++ b/lottie_loading_layout/res/layout-sw600dp-v31/sud_glif_fullscreen_loading_template_content.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-sw600dp-v31/sud_loading_fullscreen_lottie_layout.xml b/lottie_loading_layout/res/layout-sw600dp-v31/sud_loading_fullscreen_lottie_layout.xml
new file mode 100644
index 00000000..8c98e74c
--- /dev/null
+++ b/lottie_loading_layout/res/layout-sw600dp-v31/sud_loading_fullscreen_lottie_layout.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-v31/sud_glif_loading_template_content.xml b/lottie_loading_layout/res/layout-v31/sud_glif_loading_template_content.xml
new file mode 100644
index 00000000..c5d36ef0
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v31/sud_glif_loading_template_content.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-v31/sud_loading_illustration_layout.xml b/lottie_loading_layout/res/layout-v31/sud_loading_illustration_layout.xml
new file mode 100644
index 00000000..8873b42f
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v31/sud_loading_illustration_layout.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout-v31/sud_loading_lottie_layout.xml b/lottie_loading_layout/res/layout-v31/sud_loading_lottie_layout.xml
new file mode 100644
index 00000000..a38b7911
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v31/sud_loading_lottie_layout.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_card_two_pane.xml b/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_card_two_pane.xml
new file mode 100644
index 00000000..85ea0ff0
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_card_two_pane.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_content_two_pane.xml b/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_content_two_pane.xml
new file mode 100644
index 00000000..9f1bdf66
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v34/sud_glif_fullscreen_loading_template_content_two_pane.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_card_two_pane.xml b/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_card_two_pane.xml
new file mode 100644
index 00000000..9b78dff4
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_card_two_pane.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_content_two_pane.xml b/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_content_two_pane.xml
new file mode 100644
index 00000000..8e2a6d87
--- /dev/null
+++ b/lottie_loading_layout/res/layout-v34/sud_glif_loading_template_content_two_pane.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_card.xml b/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_card.xml
new file mode 100644
index 00000000..56c0f9b8
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_card.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_content.xml b/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_content.xml
new file mode 100644
index 00000000..e1dc7c47
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_fullscreen_loading_embedded_template_content.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_card.xml b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_card.xml
new file mode 100644
index 00000000..6b26c686
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_card.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_compat.xml b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_compat.xml
new file mode 100644
index 00000000..b79851a8
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_compat.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_content.xml b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_content.xml
new file mode 100644
index 00000000..a49aead8
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_embedded_template_content.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_template_card.xml b/lottie_loading_layout/res/layout/sud_glif_loading_template_card.xml
new file mode 100644
index 00000000..a2f907e6
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_template_card.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_template_compat.xml b/lottie_loading_layout/res/layout/sud_glif_loading_template_compat.xml
new file mode 100644
index 00000000..d8e9177b
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_template_compat.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_glif_loading_template_content.xml b/lottie_loading_layout/res/layout/sud_glif_loading_template_content.xml
new file mode 100644
index 00000000..e70edc1b
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_glif_loading_template_content.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/layout/sud_loading_illustration_layout.xml b/lottie_loading_layout/res/layout/sud_loading_illustration_layout.xml
new file mode 100644
index 00000000..772598e5
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_loading_illustration_layout.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/layout/sud_loading_lottie_layout.xml b/lottie_loading_layout/res/layout/sud_loading_lottie_layout.xml
new file mode 100644
index 00000000..6e00f984
--- /dev/null
+++ b/lottie_loading_layout/res/layout/sud_loading_lottie_layout.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lottie_loading_layout/res/values-sw600dp-v31/layouts.xml b/lottie_loading_layout/res/values-sw600dp-v31/layouts.xml
new file mode 100644
index 00000000..534b9563
--- /dev/null
+++ b/lottie_loading_layout/res/values-sw600dp-v31/layouts.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ - @layout/sud_glif_loading_template_card
+ - @layout/sud_glif_loading_embedded_template_card
+ - @layout/sud_glif_fullscreen_loading_template_card
+ - @layout/sud_glif_fullscreen_loading_embedded_template_card
+
diff --git a/lottie_loading_layout/res/values-sw600dp/layouts.xml b/lottie_loading_layout/res/values-sw600dp/layouts.xml
new file mode 100644
index 00000000..3afbee7a
--- /dev/null
+++ b/lottie_loading_layout/res/values-sw600dp/layouts.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ - @layout/sud_glif_loading_template_card
+ - @layout/sud_glif_loading_embedded_template_card
+
diff --git a/lottie_loading_layout/res/values-v34/layouts.xml b/lottie_loading_layout/res/values-v34/layouts.xml
new file mode 100644
index 00000000..5ae5f54c
--- /dev/null
+++ b/lottie_loading_layout/res/values-v34/layouts.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ - @layout/sud_glif_loading_template_compat
+ - @layout/sud_glif_loading_template_compat
+
diff --git a/lottie_loading_layout/res/values-w840dp-v34/layouts.xml b/lottie_loading_layout/res/values-w840dp-v34/layouts.xml
new file mode 100644
index 00000000..ff80deff
--- /dev/null
+++ b/lottie_loading_layout/res/values-w840dp-v34/layouts.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ - @layout/sud_glif_loading_template_card_two_pane
+ - @layout/sud_glif_fullscreen_loading_template_card_two_pane
+
diff --git a/lottie_loading_layout/res/values/attrs.xml b/lottie_loading_layout/res/values/attrs.xml
new file mode 100644
index 00000000..57fcac7f
--- /dev/null
+++ b/lottie_loading_layout/res/values/attrs.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lottie_loading_layout/res/values/layouts.xml b/lottie_loading_layout/res/values/layouts.xml
new file mode 100644
index 00000000..8298bb1f
--- /dev/null
+++ b/lottie_loading_layout/res/values/layouts.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ - @layout/sud_glif_loading_template_compat
+ - @layout/sud_glif_loading_embedded_template_compat
+ - @layout/sud_glif_loading_template_compat
+ - @layout/sud_glif_loading_embedded_template_compat
+
\ No newline at end of file
diff --git a/lottie_loading_layout/src/com/google/android/setupdesign/GlifLoadingLayout.java b/lottie_loading_layout/src/com/google/android/setupdesign/GlifLoadingLayout.java
new file mode 100644
index 00000000..2231a3ad
--- /dev/null
+++ b/lottie_loading_layout/src/com/google/android/setupdesign/GlifLoadingLayout.java
@@ -0,0 +1,843 @@
+/*
+ * Copyright (C) 2021 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.google.android.setupdesign;
+
+import static com.google.android.setupcompat.partnerconfig.Util.isNightMode;
+import static java.lang.Math.min;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.TypedArray;
+import android.graphics.ColorFilter;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.PersistableBundle;
+import android.provider.Settings;
+import android.provider.Settings.SettingNotFoundException;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RawRes;
+import androidx.annotation.StringDef;
+import androidx.annotation.VisibleForTesting;
+
+import com.airbnb.lottie.LottieAnimationView;
+import com.airbnb.lottie.LottieDrawable;
+import com.airbnb.lottie.LottieProperty;
+import com.airbnb.lottie.model.KeyPath;
+import com.airbnb.lottie.value.LottieValueCallback;
+import com.airbnb.lottie.value.SimpleLottieValueCallback;
+import com.google.android.setupcompat.partnerconfig.PartnerConfig;
+import com.google.android.setupcompat.partnerconfig.PartnerConfig.ResourceType;
+import com.google.android.setupcompat.partnerconfig.PartnerConfigHelper;
+import com.google.android.setupcompat.partnerconfig.ResourceEntry;
+import com.google.android.setupcompat.template.FooterBarMixin;
+import com.google.android.setupcompat.util.BuildCompatUtils;
+import com.google.android.setupcompat.util.ForceTwoPaneHelper;
+import com.google.android.setupdesign.lottieloadinglayout.R;
+import com.google.android.setupdesign.util.LayoutStyler;
+import com.google.android.setupdesign.util.LottieAnimationHelper;
+import com.google.android.setupdesign.view.IllustrationVideoView;
+import java.io.InputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A GLIF themed layout with a {@link com.airbnb.lottie.LottieAnimationView} to showing lottie
+ * illustration and a substitute {@link com.google.android.setupdesign.view.IllustrationVideoView}
+ * to showing mp4 illustration. {@code app:sudIllustrationType} can also be used to specify one of
+ * the set including "default", "account", "connection", "update", and "final_hold". {@code
+ * app:sudLottieRes} can assign the json file of Lottie resource.
+ */
+public class GlifLoadingLayout extends GlifLayout {
+
+ private static final String TAG = "GlifLoadingLayout";
+ View inflatedView;
+
+ @VisibleForTesting @IllustrationType String illustrationType = IllustrationType.DEFAULT;
+ @VisibleForTesting LottieAnimationConfig animationConfig = LottieAnimationConfig.CONFIG_DEFAULT;
+
+ @VisibleForTesting @RawRes int customLottieResource = 0;
+
+ private AnimatorListener animatorListener;
+ private Runnable nextActionRunnable;
+ private boolean workFinished;
+ protected static final String GLIF_LAYOUT_TYPE = "GlifLayoutType";
+ protected static final String LOADING_LAYOUT = "LoadingLayout";
+ @VisibleForTesting public boolean runRunnable;
+
+ @VisibleForTesting
+ public List animationFinishListeners = new ArrayList<>();
+
+ public GlifLoadingLayout(Context context) {
+ this(context, 0, 0);
+ }
+
+ public GlifLoadingLayout(Context context, int template) {
+ this(context, template, 0);
+ }
+
+ public GlifLoadingLayout(Context context, int template, int containerId) {
+ super(context, template, containerId);
+ init(null, com.google.android.setupdesign.R.attr.sudLayoutTheme);
+ }
+
+ public GlifLoadingLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(attrs, com.google.android.setupdesign.R.attr.sudLayoutTheme);
+ }
+
+ public GlifLoadingLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs, defStyleAttr);
+ }
+
+ private void init(AttributeSet attrs, int defStyleAttr) {
+ registerMixin(FooterBarMixin.class, new LoadingFooterBarMixin(this, attrs, defStyleAttr));
+
+ TypedArray a =
+ getContext()
+ .obtainStyledAttributes(attrs, R.styleable.SudGlifLoadingLayout, defStyleAttr, 0);
+ customLottieResource = a.getResourceId(R.styleable.SudGlifLoadingLayout_sudLottieRes, 0);
+ String illustrationType = a.getString(R.styleable.SudGlifLoadingLayout_sudIllustrationType);
+ a.recycle();
+
+ if (customLottieResource != 0) {
+ inflateLottieView();
+ ViewGroup container = findContainer(0);
+ container.setVisibility(View.VISIBLE);
+ } else {
+ if (illustrationType != null) {
+ setIllustrationType(illustrationType);
+ }
+
+ if (BuildCompatUtils.isAtLeastS()) {
+ inflateLottieView();
+ } else {
+ inflateIllustrationStub();
+ }
+ }
+
+ View view = findManagedViewById(R.id.sud_layout_loading_content);
+ if (view != null) {
+ if (shouldApplyPartnerResource()) {
+ LayoutStyler.applyPartnerCustomizationExtraPaddingStyle(view);
+ }
+ tryApplyPartnerCustomizationContentPaddingTopStyle(view);
+ }
+
+ updateHeaderHeight();
+ updateLandscapeMiddleHorizontalSpacing();
+
+ workFinished = false;
+ runRunnable = true;
+
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ /*
+ * add the listener used to log animation end and check whether the
+ * work in background finish when repeated.
+ */
+ animatorListener =
+ new AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ Log.i(TAG, "Animate enable:" + isAnimateEnable() + ". Animation end.");
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animation) {
+ if (workFinished) {
+ Log.i(TAG, "Animation repeat but work finished, run the register runnable.");
+ finishRunnable(nextActionRunnable);
+ workFinished = false;
+ }
+ }
+ };
+ lottieAnimationView.addAnimatorListener(animatorListener);
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ if (inflatedView instanceof LinearLayout) {
+ updateContentPadding((LinearLayout) inflatedView);
+ }
+ }
+
+ private boolean isAnimateEnable() {
+ try {
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
+ return Settings.Global.getFloat(
+ getContext().getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE)
+ != 0f;
+ } else {
+ return true;
+ }
+
+ } catch (SettingNotFoundException e) {
+ return true;
+ }
+ }
+
+ public void setIllustrationType(@IllustrationType String type) {
+ if (customLottieResource != 0) {
+ throw new IllegalStateException(
+ "custom illustration already applied, should not set illustration.");
+ }
+
+ if (!illustrationType.equals(type)) {
+ illustrationType = type;
+ }
+
+ switch (type) {
+ case IllustrationType.ACCOUNT:
+ animationConfig = LottieAnimationConfig.CONFIG_ACCOUNT;
+ break;
+
+ case IllustrationType.CONNECTION:
+ animationConfig = LottieAnimationConfig.CONFIG_CONNECTION;
+ break;
+
+ case IllustrationType.UPDATE:
+ animationConfig = LottieAnimationConfig.CONFIG_UPDATE;
+ break;
+
+ case IllustrationType.FINAL_HOLD:
+ animationConfig = LottieAnimationConfig.CONFIG_FINAL_HOLD;
+ break;
+
+ default:
+ animationConfig = LottieAnimationConfig.CONFIG_DEFAULT;
+ break;
+ }
+
+ updateAnimationView();
+ }
+
+ // TODO: [GlifLoadingLayout] Should add testcase. LottieAnimationView was auto
+ // generated not able to mock. So we have no idea how to detected is the api pass to
+ // LottiAnimationView correctly.
+ public boolean setAnimation(InputStream inputStream, String keyCache) {
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ lottieAnimationView.setAnimation(inputStream, keyCache);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public boolean setAnimation(String assetName) {
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ lottieAnimationView.setAnimation(assetName);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public boolean setAnimation(@RawRes int rawRes) {
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ lottieAnimationView.setAnimation(rawRes);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private void updateAnimationView() {
+ if (BuildCompatUtils.isAtLeastS()) {
+ setLottieResource();
+ } else {
+ setIllustrationResource();
+ }
+ }
+
+ /**
+ * Call this when your activity is done and should be closed. The activity will be finished while
+ * animation finished.
+ */
+ public void finish(@NonNull Activity activity) {
+ if (activity == null) {
+ throw new NullPointerException("activity should not be null");
+ }
+ registerAnimationFinishRunnable(activity::finish);
+ }
+
+ private void updateHeaderHeight() {
+ View headerView = findManagedViewById(R.id.sud_header_scroll_view);
+ Configuration currentConfig = getResources().getConfiguration();
+ if (headerView != null
+ && PartnerConfigHelper.get(getContext())
+ .isPartnerConfigAvailable(PartnerConfig.CONFIG_LOADING_LAYOUT_HEADER_HEIGHT)
+ && currentConfig.orientation != Configuration.ORIENTATION_LANDSCAPE) {
+ float configHeaderHeight =
+ PartnerConfigHelper.get(getContext())
+ .getDimension(getContext(), PartnerConfig.CONFIG_LOADING_LAYOUT_HEADER_HEIGHT);
+ headerView.getLayoutParams().height = (int) configHeaderHeight;
+ }
+ }
+
+ private void updateContentPadding(LinearLayout linearLayout) {
+ int paddingTop = linearLayout.getPaddingTop();
+ int paddingLeft = linearLayout.getPaddingLeft();
+ int paddingRight = linearLayout.getPaddingRight();
+ int paddingBottom = linearLayout.getPaddingBottom();
+
+ if (PartnerConfigHelper.get(getContext())
+ .isPartnerConfigAvailable(PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_TOP)) {
+ float configPaddingTop =
+ PartnerConfigHelper.get(getContext())
+ .getDimension(getContext(), PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_TOP);
+ if (configPaddingTop >= 0) {
+ paddingTop = (int) configPaddingTop;
+ }
+ }
+
+ if (PartnerConfigHelper.get(getContext())
+ .isPartnerConfigAvailable(PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_START)) {
+ float configPaddingLeft =
+ PartnerConfigHelper.get(getContext())
+ .getDimension(getContext(), PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_START);
+ if (configPaddingLeft >= 0) {
+ paddingLeft = (int) configPaddingLeft;
+ }
+ }
+
+ if (PartnerConfigHelper.get(getContext())
+ .isPartnerConfigAvailable(PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_END)) {
+ float configPaddingRight =
+ PartnerConfigHelper.get(getContext())
+ .getDimension(getContext(), PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_END);
+ if (configPaddingRight >= 0) {
+ paddingRight = (int) configPaddingRight;
+ }
+ }
+
+ if (PartnerConfigHelper.get(getContext())
+ .isPartnerConfigAvailable(PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_BOTTOM)) {
+ float configPaddingBottom =
+ PartnerConfigHelper.get(getContext())
+ .getDimension(getContext(), PartnerConfig.CONFIG_LOADING_LAYOUT_PADDING_BOTTOM);
+ if (configPaddingBottom >= 0) {
+ FooterBarMixin footerBarMixin = getMixin(FooterBarMixin.class);
+ if (footerBarMixin == null || footerBarMixin.getButtonContainer() == null) {
+ paddingBottom = (int) configPaddingBottom;
+ } else {
+ paddingBottom =
+ (int) configPaddingBottom
+ - (int)
+ min(
+ configPaddingBottom,
+ getButtonContainerHeight(footerBarMixin.getButtonContainer()));
+ }
+ }
+ }
+
+ linearLayout.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
+ }
+
+ private static int getButtonContainerHeight(View view) {
+ view.measure(
+ MeasureSpec.makeMeasureSpec(view.getMeasuredWidth(), MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(view.getMeasuredHeight(), MeasureSpec.EXACTLY));
+ return view.getMeasuredHeight();
+ }
+
+ private void inflateLottieView() {
+ final View lottieLayout = peekLottieLayout();
+ if (lottieLayout == null) {
+ ViewStub viewStub = findManagedViewById(R.id.sud_loading_layout_lottie_stub);
+ if (viewStub != null) {
+ inflatedView = viewStub.inflate();
+ if (inflatedView instanceof LinearLayout) {
+ updateContentPadding((LinearLayout) inflatedView);
+ }
+ setLottieResource();
+ }
+ }
+ }
+
+ private void inflateIllustrationStub() {
+ final View progressLayout = peekProgressIllustrationLayout();
+ if (progressLayout == null) {
+ ViewStub viewStub = findManagedViewById(R.id.sud_loading_layout_illustration_stub);
+ if (viewStub != null) {
+ inflatedView = viewStub.inflate();
+ if (inflatedView instanceof LinearLayout) {
+ updateContentPadding((LinearLayout) inflatedView);
+ }
+ setIllustrationResource();
+ }
+ }
+ }
+
+ private void setLottieResource() {
+ LottieAnimationView lottieView = findViewById(R.id.sud_lottie_view);
+ if (lottieView == null) {
+ Log.w(TAG, "Lottie view not found, skip set resource. Wait for layout inflated.");
+ return;
+ }
+ if (customLottieResource != 0) {
+ InputStream inputRaw = getResources().openRawResource(customLottieResource);
+ lottieView.setAnimation(inputRaw, null);
+ lottieView.playAnimation();
+ } else {
+ PartnerConfigHelper partnerConfigHelper = PartnerConfigHelper.get(getContext());
+ ResourceEntry resourceEntry =
+ partnerConfigHelper.getIllustrationResourceEntry(
+ getContext(), animationConfig.getLottieConfig());
+
+ if (resourceEntry != null) {
+ InputStream inputRaw =
+ resourceEntry.getResources().openRawResource(resourceEntry.getResourceId());
+ lottieView.setAnimation(inputRaw, null);
+ lottieView.playAnimation();
+ setLottieLayoutVisibility(View.VISIBLE);
+ setIllustrationLayoutVisibility(View.GONE);
+ LottieAnimationHelper.get()
+ .applyColor(
+ getContext(),
+ findLottieAnimationView(),
+ isNightMode(getResources().getConfiguration())
+ ? animationConfig.getDarkThemeCustomization()
+ : animationConfig.getLightThemeCustomization());
+ } else {
+ setLottieLayoutVisibility(View.GONE);
+ setIllustrationLayoutVisibility(View.VISIBLE);
+ inflateIllustrationStub();
+ }
+ }
+ }
+
+ private void setIllustrationLayoutVisibility(int visibility) {
+ View illustrationLayout = findViewById(R.id.sud_layout_progress_illustration);
+ if (illustrationLayout != null) {
+ illustrationLayout.setVisibility(visibility);
+ }
+ }
+
+ private void setLottieLayoutVisibility(int visibility) {
+ View lottieLayout = findViewById(R.id.sud_layout_lottie_illustration);
+ if (lottieLayout != null) {
+ lottieLayout.setVisibility(visibility);
+ }
+ }
+
+ @VisibleForTesting
+ boolean isLottieLayoutVisible() {
+ View lottieLayout = findViewById(R.id.sud_layout_lottie_illustration);
+ return lottieLayout != null && lottieLayout.getVisibility() == View.VISIBLE;
+ }
+
+ private void setIllustrationResource() {
+ View illustrationLayout = findViewById(R.id.sud_layout_progress_illustration);
+ if (illustrationLayout == null) {
+ Log.i(TAG, "Illustration stub not inflated, skip set resource");
+ return;
+ }
+
+ IllustrationVideoView illustrationVideoView =
+ findManagedViewById(R.id.sud_progress_illustration);
+ ProgressBar progressBar = findManagedViewById(R.id.sud_progress_bar);
+
+ PartnerConfigHelper partnerConfigHelper = PartnerConfigHelper.get(getContext());
+ ResourceEntry resourceEntry =
+ partnerConfigHelper.getIllustrationResourceEntry(
+ getContext(), animationConfig.getIllustrationConfig());
+
+ if (resourceEntry != null) {
+ progressBar.setVisibility(GONE);
+ illustrationVideoView.setVisibility(VISIBLE);
+ illustrationVideoView.setVideoResourceEntry(resourceEntry);
+ } else {
+ progressBar.setVisibility(VISIBLE);
+ illustrationVideoView.setVisibility(GONE);
+ }
+ }
+
+ private LottieAnimationView findLottieAnimationView() {
+ return findViewById(R.id.sud_lottie_view);
+ }
+
+ private IllustrationVideoView findIllustrationVideoView() {
+ return findManagedViewById(R.id.sud_progress_illustration);
+ }
+
+ public void playAnimation() {
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ lottieAnimationView.setRepeatCount(LottieDrawable.INFINITE);
+ lottieAnimationView.playAnimation();
+ }
+ }
+
+ /** Returns whether the layout is waiting for animation finish or not. */
+ public boolean isFinishing() {
+ LottieAnimationView lottieAnimationView = findLottieAnimationView();
+ if (lottieAnimationView != null) {
+ return !animationFinishListeners.isEmpty() && lottieAnimationView.getRepeatCount() == 0;
+ } else {
+ return false;
+ }
+ }
+
+ @AnimationType
+ public int getAnimationType() {
+ if (findLottieAnimationView() != null && isLottieLayoutVisible()) {
+ return AnimationType.LOTTIE;
+ } else if (findIllustrationVideoView() != null) {
+ return AnimationType.ILLUSTRATION;
+ } else {
+ return AnimationType.PROGRESS_BAR;
+ }
+ }
+
+ // TODO: Should add testcase with mocked LottieAnimationView.
+ /** Add an animator listener to {@link LottieAnimationView}. */
+ public void addAnimatorListener(Animator.AnimatorListener listener) {
+ LottieAnimationView animationView = findLottieAnimationView();
+ if (animationView != null) {
+ animationView.addAnimatorListener(listener);
+ }
+ }
+
+ /** Remove the listener from {@link LottieAnimationView}. */
+ public void removeAnimatorListener(AnimatorListener listener) {
+ LottieAnimationView animationView = findLottieAnimationView();
+ if (animationView != null) {
+ animationView.removeAnimatorListener(listener);
+ }
+ }
+
+ /** Remove all {@link AnimatorListener} from {@link LottieAnimationView}. */
+ public void removeAllAnimatorListener() {
+ LottieAnimationView animationView = findLottieAnimationView();
+ if (animationView != null) {
+ animationView.removeAllAnimatorListeners();
+ }
+ }
+
+ /** Add a value callback with property {@link LottieProperty.COLOR_FILTER}. */
+ public void addColorCallback(KeyPath keyPath, LottieValueCallback callback) {
+ LottieAnimationView animationView = findLottieAnimationView();
+ if (animationView != null) {
+ animationView.addValueCallback(keyPath, LottieProperty.COLOR_FILTER, callback);
+ }
+ }
+
+ /** Add a simple value callback with property {@link LottieProperty.COLOR_FILTER}. */
+ public void addColorCallback(KeyPath keyPath, SimpleLottieValueCallback callback) {
+ LottieAnimationView animationView = findLottieAnimationView();
+ if (animationView != null) {
+ animationView.addValueCallback(keyPath, LottieProperty.COLOR_FILTER, callback);
+ }
+ }
+
+ @Nullable
+ private View peekLottieLayout() {
+ return findViewById(R.id.sud_layout_lottie_illustration);
+ }
+
+ @Nullable
+ private View peekProgressIllustrationLayout() {
+ return findViewById(R.id.sud_layout_progress_illustration);
+ }
+
+ @Override
+ protected View onInflateTemplate(LayoutInflater inflater, int template) {
+ Context context = getContext();
+ if (template == 0) {
+ boolean useFullScreenIllustration =
+ PartnerConfigHelper.get(context)
+ .getBoolean(
+ context,
+ PartnerConfig.CONFIG_LOADING_LAYOUT_FULL_SCREEN_ILLUSTRATION_ENABLED,
+ false);
+ if (useFullScreenIllustration) {
+ template = R.layout.sud_glif_fullscreen_loading_template;
+
+ // if the activity is embedded should apply an embedded layout.
+ if (isEmbeddedActivityOnePaneEnabled(context)) {
+ template = R.layout.sud_glif_fullscreen_loading_embedded_template;
+ } else if (ForceTwoPaneHelper.isForceTwoPaneEnable(getContext())) {
+ template = ForceTwoPaneHelper.getForceTwoPaneStyleLayout(getContext(), template);
+ }
+ } else {
+ template = R.layout.sud_glif_loading_template;
+
+ // if the activity is embedded should apply an embedded layout.
+ if (isEmbeddedActivityOnePaneEnabled(context)) {
+ template = R.layout.sud_glif_loading_embedded_template;
+ } else if (ForceTwoPaneHelper.isForceTwoPaneEnable(getContext())) {
+ template = ForceTwoPaneHelper.getForceTwoPaneStyleLayout(getContext(), template);
+ }
+ }
+ }
+ return inflateTemplate(
+ inflater, com.google.android.setupdesign.R.style.SudThemeGlif_Light, template);
+ }
+
+ @Override
+ protected ViewGroup findContainer(int containerId) {
+ if (containerId == 0) {
+ containerId = R.id.sud_layout_content;
+ }
+ return super.findContainer(containerId);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ if (VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ PersistableBundle bundle = new PersistableBundle();
+ bundle.putString(GLIF_LAYOUT_TYPE, LOADING_LAYOUT);
+ setLayoutTypeMetrics(bundle);
+ super.onDetachedFromWindow();
+ }
+ }
+
+ /** The progress config used to maps to different animation */
+ public enum LottieAnimationConfig {
+ CONFIG_DEFAULT(
+ PartnerConfig.CONFIG_PROGRESS_ILLUSTRATION_DEFAULT,
+ PartnerConfig.CONFIG_LOADING_LOTTIE_DEFAULT,
+ PartnerConfig.CONFIG_LOTTIE_LIGHT_THEME_CUSTOMIZATION_DEFAULT,
+ PartnerConfig.CONFIG_LOTTIE_DARK_THEME_CUSTOMIZATION_DEFAULT),
+ CONFIG_ACCOUNT(
+ PartnerConfig.CONFIG_PROGRESS_ILLUSTRATION_ACCOUNT,
+ PartnerConfig.CONFIG_LOADING_LOTTIE_ACCOUNT,
+ PartnerConfig.CONFIG_LOTTIE_LIGHT_THEME_CUSTOMIZATION_ACCOUNT,
+ PartnerConfig.CONFIG_LOTTIE_DARK_THEME_CUSTOMIZATION_ACCOUNT),
+ CONFIG_CONNECTION(
+ PartnerConfig.CONFIG_PROGRESS_ILLUSTRATION_CONNECTION,
+ PartnerConfig.CONFIG_LOADING_LOTTIE_CONNECTION,
+ PartnerConfig.CONFIG_LOTTIE_LIGHT_THEME_CUSTOMIZATION_CONNECTION,
+ PartnerConfig.CONFIG_LOTTIE_DARK_THEME_CUSTOMIZATION_CONNECTION),
+ CONFIG_UPDATE(
+ PartnerConfig.CONFIG_PROGRESS_ILLUSTRATION_UPDATE,
+ PartnerConfig.CONFIG_LOADING_LOTTIE_UPDATE,
+ PartnerConfig.CONFIG_LOTTIE_LIGHT_THEME_CUSTOMIZATION_UPDATE,
+ PartnerConfig.CONFIG_LOTTIE_DARK_THEME_CUSTOMIZATION_UPDATE),
+ CONFIG_FINAL_HOLD(
+ PartnerConfig.CONFIG_PROGRESS_ILLUSTRATION_FINAL_HOLD,
+ PartnerConfig.CONFIG_LOADING_LOTTIE_FINAL_HOLD,
+ PartnerConfig.CONFIG_LOTTIE_LIGHT_THEME_CUSTOMIZATION_FINAL_HOLD,
+ PartnerConfig.CONFIG_LOTTIE_DARK_THEME_CUSTOMIZATION_FINAL_HOLD);
+
+ private final PartnerConfig illustrationConfig;
+ private final PartnerConfig lottieConfig;
+ private final PartnerConfig lightThemeCustomization;
+ private final PartnerConfig darkThemeCustomization;
+
+ LottieAnimationConfig(
+ PartnerConfig illustrationConfig,
+ PartnerConfig lottieConfig,
+ PartnerConfig lightThemeCustomization,
+ PartnerConfig darkThemeCustomization) {
+ if (illustrationConfig.getResourceType() != ResourceType.ILLUSTRATION
+ || lottieConfig.getResourceType() != ResourceType.ILLUSTRATION) {
+ throw new IllegalArgumentException(
+ "Illustration progress only allow illustration resource");
+ }
+ this.illustrationConfig = illustrationConfig;
+ this.lottieConfig = lottieConfig;
+ this.lightThemeCustomization = lightThemeCustomization;
+ this.darkThemeCustomization = darkThemeCustomization;
+ }
+
+ PartnerConfig getIllustrationConfig() {
+ return illustrationConfig;
+ }
+
+ PartnerConfig getLottieConfig() {
+ return lottieConfig;
+ }
+
+ PartnerConfig getLightThemeCustomization() {
+ return lightThemeCustomization;
+ }
+
+ PartnerConfig getDarkThemeCustomization() {
+ return darkThemeCustomization;
+ }
+ }
+
+ /**
+ * Register the {@link Runnable} as a callback that will be performed when the animation finished.
+ */
+ public void registerAnimationFinishRunnable(Runnable runnable) {
+ workFinished = true;
+ nextActionRunnable = runnable;
+ synchronized (this) {
+ runRunnable = true;
+ animationFinishListeners.add(
+ new LottieAnimationFinishListener(this, () -> finishRunnable(runnable)));
+ }
+ }
+
+ @VisibleForTesting
+ public synchronized void finishRunnable(Runnable runnable) {
+ // to avoid run the runnable twice.
+ if (runRunnable) {
+ runnable.run();
+ }
+ runRunnable = false;
+ }
+
+ /** The listener that to indicate the playing status for lottie animation. */
+ @VisibleForTesting
+ public static class LottieAnimationFinishListener {
+
+ private final Runnable runnable;
+ private final GlifLoadingLayout glifLoadingLayout;
+ private final LottieAnimationView lottieAnimationView;
+
+ @VisibleForTesting
+ AnimatorListener animatorListener =
+ new AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animation) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ onAnimationFinished();
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animation) {
+ // Do nothing.
+ }
+ };
+
+ @VisibleForTesting
+ LottieAnimationFinishListener(GlifLoadingLayout glifLoadingLayout, Runnable runnable) {
+ if (runnable == null) {
+ throw new NullPointerException("Runnable can not be null");
+ }
+ this.glifLoadingLayout = glifLoadingLayout;
+ this.runnable = runnable;
+ this.lottieAnimationView = glifLoadingLayout.findLottieAnimationView();
+
+ boolean shouldAnimationBeFinished =
+ PartnerConfigHelper.get(glifLoadingLayout.getContext())
+ .getBoolean(
+ glifLoadingLayout.getContext(),
+ PartnerConfig.CONFIG_LOADING_LAYOUT_WAIT_FOR_ANIMATION_FINISHED,
+ true);
+ // TODO: add test case for verify the case which isAnimating returns true.
+ if (glifLoadingLayout.isLottieLayoutVisible()
+ && lottieAnimationView.isAnimating()
+ && !isZeroAnimatorDurationScale()
+ && shouldAnimationBeFinished) {
+ Log.i(TAG, "Register animation finish.");
+ lottieAnimationView.addAnimatorListener(animatorListener);
+ lottieAnimationView.setRepeatCount(0);
+ } else {
+ onAnimationFinished();
+ }
+ }
+
+ @VisibleForTesting
+ boolean isZeroAnimatorDurationScale() {
+ try {
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
+ return Settings.Global.getFloat(
+ glifLoadingLayout.getContext().getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE)
+ == 0f;
+ } else {
+ return false;
+ }
+
+ } catch (SettingNotFoundException e) {
+ return false;
+ }
+ }
+
+ @VisibleForTesting
+ public void onAnimationFinished() {
+ runnable.run();
+ if (lottieAnimationView != null) {
+ lottieAnimationView.removeAnimatorListener(animatorListener);
+ }
+ glifLoadingLayout.animationFinishListeners.remove(this);
+ }
+ }
+
+ /** Annotates the state for the illustration. */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ IllustrationType.ACCOUNT,
+ IllustrationType.CONNECTION,
+ IllustrationType.DEFAULT,
+ IllustrationType.UPDATE,
+ IllustrationType.FINAL_HOLD
+ })
+ public @interface IllustrationType {
+ String DEFAULT = "default";
+ String ACCOUNT = "account";
+ String CONNECTION = "connection";
+ String UPDATE = "update";
+ String FINAL_HOLD = "final_hold";
+ }
+
+ /** Annotates the type for the illustration. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({AnimationType.LOTTIE, AnimationType.ILLUSTRATION, AnimationType.PROGRESS_BAR})
+ public @interface AnimationType {
+ int LOTTIE = 1;
+ int ILLUSTRATION = 2;
+ int PROGRESS_BAR = 3;
+ }
+}
diff --git a/lottie_loading_layout/src/com/google/android/setupdesign/LoadingFooterBarMixin.java b/lottie_loading_layout/src/com/google/android/setupdesign/LoadingFooterBarMixin.java
new file mode 100644
index 00000000..fcc2fff8
--- /dev/null
+++ b/lottie_loading_layout/src/com/google/android/setupdesign/LoadingFooterBarMixin.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2021 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.google.android.setupdesign;
+
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import androidx.annotation.Nullable;
+import com.google.android.setupcompat.internal.TemplateLayout;
+import com.google.android.setupcompat.template.FooterBarMixin;
+
+/** A {@link Mixin} to get the container of footer bar for usage. */
+public class LoadingFooterBarMixin extends FooterBarMixin {
+
+ /**
+ * Creates a mixin for managing buttons on the footer.
+ *
+ * @param layout The {@link TemplateLayout} containing this mixin.
+ * @param attrs XML attributes given to the layout.
+ * @param defStyleAttr The default style attribute as given to the constructor of the layout.
+ */
+ public LoadingFooterBarMixin(
+ TemplateLayout layout, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(layout, attrs, defStyleAttr);
+ }
+
+ @Override
+ public LinearLayout getButtonContainer() {
+ return super.getButtonContainer();
+ }
+}
diff --git a/lottie_loading_layout/src/com/google/android/setupdesign/util/LottieAnimationHelper.java b/lottie_loading_layout/src/com/google/android/setupdesign/util/LottieAnimationHelper.java
new file mode 100644
index 00000000..5a59c40d
--- /dev/null
+++ b/lottie_loading_layout/src/com/google/android/setupdesign/util/LottieAnimationHelper.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2022 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.google.android.setupdesign.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import com.airbnb.lottie.LottieAnimationView;
+import com.airbnb.lottie.LottieProperty;
+import com.airbnb.lottie.SimpleColorFilter;
+import com.airbnb.lottie.model.KeyPath;
+import com.airbnb.lottie.value.LottieValueCallback;
+import com.google.android.setupcompat.partnerconfig.PartnerConfig;
+import com.google.android.setupcompat.partnerconfig.PartnerConfigHelper;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** A helper to help apply color on lottie animation */
+public class LottieAnimationHelper {
+
+ private static final String TAG = "LottieAnimationHelper";
+
+ private static LottieAnimationHelper instance = null;
+
+ @VisibleForTesting public final Map colorResourceMapping;
+
+ public static LottieAnimationHelper get() {
+ if (instance == null) {
+ instance = new LottieAnimationHelper();
+ }
+ return instance;
+ }
+
+ private LottieAnimationHelper() {
+ colorResourceMapping = new HashMap<>();
+ }
+
+ /**
+ * The color resource is from PartnerConfig, which is a string array and each string will be
+ * {key_path_name}:@{color_reference} or {key_path_name}:{color code}
+ */
+ public void applyColor(
+ @NonNull Context context, LottieAnimationView lottieView, PartnerConfig partnerConfig) {
+ applyColor(
+ context,
+ lottieView,
+ PartnerConfigHelper.get(context).getStringArray(context, partnerConfig));
+ }
+
+ /**
+ * The color resource is from list of string and each string will be
+ * {key_path_name}:@{color_reference} or {key_path_name}:#{color code}
+ */
+ public void applyColor(
+ @NonNull Context context, LottieAnimationView lottieView, List colorMappings) {
+ applyColor(context, lottieView, parseColorMapping(context, colorMappings));
+ }
+
+ /**
+ * The color resource is from a color mapping table and the key is the keypath, and value is color
+ * Integer.
+ */
+ public void applyColor(
+ @NonNull Context context,
+ LottieAnimationView lottieView,
+ Map colorMappings) {
+ for (KeyPath keyPath : colorMappings.keySet()) {
+ lottieView.addValueCallback(
+ keyPath,
+ LottieProperty.COLOR_FILTER,
+ new LottieValueCallback<>(new SimpleColorFilter(colorMappings.get(keyPath))));
+ }
+ }
+
+ private Map parseColorMapping(
+ @NonNull Context context, List colorMappings) {
+ Map customizationMap = new HashMap<>();
+ for (String colorMapping : colorMappings) {
+ String[] splitItem = colorMapping.split(":");
+ if (splitItem.length == 2) {
+ if (splitItem[1].charAt(0) == '#') { // color code
+ try {
+ customizationMap.put(
+ new KeyPath("**", splitItem[0], "**"), Color.parseColor(splitItem[1]));
+ } catch (IllegalArgumentException exception) {
+ Log.e(TAG, "Unknown color, value=" + colorMapping);
+ }
+ } else if (splitItem[1].charAt(0) == '@') { // color resource
+ int colorResourceId;
+ if (colorResourceMapping.containsKey(splitItem[1])) {
+ colorResourceId = colorResourceMapping.get(splitItem[1]);
+ } else {
+ colorResourceId =
+ context
+ .getResources()
+ .getIdentifier(splitItem[1].substring(1), "color", context.getPackageName());
+ colorResourceMapping.put(splitItem[1], colorResourceId);
+ }
+ try {
+ customizationMap.put(
+ new KeyPath("**", splitItem[0], "**"),
+ context.getResources().getColor(colorResourceId, null));
+ } catch (Resources.NotFoundException exception) {
+ Log.e(TAG, "Resource Not found, resource value=" + colorMapping);
+ }
+ } else {
+ Log.w(TAG, "incorrect format customization, value=" + colorMapping);
+ }
+ } else {
+ Log.w(TAG, "incorrect format customization, value=" + colorMapping);
+ }
+ }
+ return customizationMap;
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index 9e0e23df..d31faaf3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -9,6 +9,7 @@ pluginManagement {
}
mavenCentral()
gradlePluginPortal()
+ maven { url 'https://jitpack.io' }
}
}
dependencyResolutionManagement {
@@ -16,6 +17,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url 'https://jitpack.io' }
}
}
@@ -32,6 +34,7 @@ include ':setupdesign'
include ':SettingsLib'
include ':SettingsLib:ActionBarShadow'
include ':SettingsLib:ActionButtonsPreference'
+include ':SettingsLib:ActivityEmbedding'
include ':SettingsLib:AdaptiveIcon'
include ':SettingsLib:AppPreference'
include ':SettingsLib:BannerMessagePreference'
@@ -39,6 +42,7 @@ include ':SettingsLib:BarChartPreference'
include ':SettingsLib:ButtonPreference'
include ':SettingsLib:CollapsingToolbarBaseActivity'
include ':SettingsLib:Color'
+include ':SettingsLib:DataStore'
include ':SettingsLib:DeviceStateRotationLock'
include ':SettingsLib:DisplayUtils'
include ':SettingsLib:EmergencyNumber'
@@ -56,6 +60,8 @@ include ':SettingsLib:SelectorWithWidgetPreference'
include ':SettingsLib:SettingsSpinner'
include ':SettingsLib:SettingsTheme'
include ':SettingsLib:SettingsTransition'
+include ':spa'
+include ':SettingsLib:SpaPrivileged'
include ':SettingsLib:Tile'
include ':SettingsLib:TopIntroPreference'
include ':SettingsLib:TwoTargetPreference'
@@ -67,3 +73,4 @@ include ':SettingsLib:Utils'
//include ':car-apps-common'
//include ':car-qc-lib'
include ':SettingsLib:search'
+include ':lottie_loading_layout'
diff --git a/spa/Android.bp b/spa/Android.bp
new file mode 100644
index 00000000..6df0e995
--- /dev/null
+++ b/spa/Android.bp
@@ -0,0 +1,56 @@
+//
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package {
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_library {
+ name: "SpaLib",
+
+ srcs: ["src/**/*.kt"],
+ use_resource_processor: true,
+ static_libs: [
+ "SettingsLibColor",
+ "androidx.slice_slice-builders",
+ "androidx.slice_slice-core",
+ "androidx.slice_slice-view",
+ "androidx.compose.animation_animation",
+ "androidx.compose.material3_material3",
+ "androidx.compose.material_material-icons-extended",
+ "androidx.compose.runtime_runtime",
+ "androidx.compose.runtime_runtime-livedata",
+ "androidx.compose.ui_ui-tooling-preview",
+ "androidx.lifecycle_lifecycle-livedata-ktx",
+ "androidx.lifecycle_lifecycle-runtime-compose",
+ "androidx.navigation_navigation-compose",
+ "com.google.android.material_material",
+ "lottie_compose",
+ "MPAndroidChart",
+ ],
+ kotlincflags: [
+ "-Xjvm-default=all",
+ ],
+ sdk_version: "current",
+ min_sdk_version: "31",
+}
+
+// Expose the srcs to tests, so the tests can access the internal classes.
+filegroup {
+ name: "SpaLib_srcs",
+ visibility: ["//frameworks/base/packages/SettingsLib/Spa/tests"],
+ srcs: ["src/**/*.kt"],
+}
diff --git a/spa/AndroidManifest.xml b/spa/AndroidManifest.xml
new file mode 100644
index 00000000..62800bd3
--- /dev/null
+++ b/spa/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/spa/build.gradle b/spa/build.gradle
new file mode 100644
index 00000000..d8d4a2b7
--- /dev/null
+++ b/spa/build.gradle
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ id 'jacoco'
+}
+
+ext.jetpackComposeVersion = '1.3.0-alpha01' // 请根据实际版本号进行调整
+
+android {
+ namespace = "com.android.settingslib.spa"
+ compileSdkVersion = 34
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ main {
+ kotlin.srcDirs = ['src']
+ res.srcDirs = ['res']
+ manifest.srcFile 'AndroidManifest.xml'
+ }
+ androidTest {
+ kotlin.srcDirs = ['../tests/src']
+ res.srcDirs = ['../tests/res']
+ manifest.srcFile '../tests/AndroidManifest.xml'
+ }
+ }
+ buildFeatures {
+ compose = true
+ }
+ buildTypes {
+ debug {
+ enableAndroidTestCoverage = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = '1.5.0' // 假设1.4.0支持Kotlin 1.9.0
+ }
+}
+
+dependencies {
+ api project(':SettingsLib:Color')
+ api 'androidx.appcompat:appcompat:1.7.0-alpha03'
+ api 'androidx.slice:slice-builders:1.1.0-alpha02'
+ api 'androidx.slice:slice-core:1.1.0-alpha02'
+ api 'androidx.slice:slice-view:1.1.0-alpha02'
+ api "androidx.compose.material3:material3:1.3.0"
+ api "androidx.compose.material:material-icons-extended:1.5.0"
+ api "androidx.compose.runtime:runtime-livedata:1.5.0"
+ api "androidx.compose.ui:ui-tooling-preview:1.5.0"
+ api 'androidx.lifecycle:lifecycle-livedata-ktx'
+ api 'androidx.lifecycle:lifecycle-runtime-compose'
+ api 'androidx.navigation:navigation-compose:2.8.0-alpha02'
+ api 'com.github.PhilJay:MPAndroidChart:v3.1.0'
+ api 'com.google.android.material:material:1.7.0-alpha03'
+ debugApi "androidx.compose.ui:ui-tooling:1.5.0"
+ implementation 'com.airbnb.android:lottie-compose:5.2.0'
+
+// androidTestImplementation project(':testutils')
+// androidTestImplementation libs.dexmaker.mockito
+}
+
+tasks.register('coverageReport', JacocoReport) {
+ group = 'Reporting'
+ description = 'Generate Jacoco coverage reports after running tests.'
+ dependsOn 'connectedDebugAndroidTest'
+ sourceDirectories.setFrom files('src')
+ classDirectories.setFrom fileTree(dir: "${layout.buildDirectory.dir('tmp/kotlin-classes/debug')}", excludes: [
+ 'com/android/settingslib/spa/debug/**',
+ 'com/android/settingslib/spa/widget/scaffold/CustomizedAppBar*',
+ 'com/android/settingslib/spa/widget/scaffold/TopAppBarColors*',
+ 'com/android/settingslib/spa/framework/compose/DrawablePainter*',
+ 'com/android/settingslib/spa/framework/util/Collections*',
+ 'com/android/settingslib/spa/framework/util/Flows*',
+ 'com/android/settingslib/spa/framework/compose/TimeMeasurer*',
+ 'com/android/settingslib/spa/slice/presenter/Demo*',
+ 'com/android/settingslib/spa/slice/provider/Demo*',
+ ])
+ executionData.setFrom fileTree(dir: "${layout.buildDirectory.dir('outputs/code_coverage/debugAndroidTest/connected')}")
+}
diff --git a/spa/build.gradle.kts b/spa/build.gradle.kts
new file mode 100644
index 00000000..7fdb3511
--- /dev/null
+++ b/spa/build.gradle.kts
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ jacoco
+}
+
+val jetpackComposeVersion: String? by extra
+
+android {
+ namespace = "com.android.settingslib.spa"
+
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ sourceSets.getByName("main") {
+ kotlin.setSrcDirs(listOf("src"))
+ res.setSrcDirs(listOf("res"))
+ manifest.srcFile("AndroidManifest.xml")
+ }
+ sourceSets.getByName("androidTest") {
+ kotlin.setSrcDirs(listOf("../tests/src"))
+ res.setSrcDirs(listOf("../tests/res"))
+ manifest.srcFile("../tests/AndroidManifest.xml")
+ }
+ }
+ buildFeatures {
+ compose = true
+ }
+ buildTypes {
+ getByName("debug") {
+ enableAndroidTestCoverage = true
+ }
+ }
+}
+
+dependencies {
+ api(project(":SettingsLib:Color"))
+ api("androidx.appcompat:appcompat:1.7.0-alpha03")
+ api("androidx.slice:slice-builders:1.1.0-alpha02")
+ api("androidx.slice:slice-core:1.1.0-alpha02")
+ api("androidx.slice:slice-view:1.1.0-alpha02")
+ api("androidx.compose.material3:material3:1.3.0-alpha01")
+ api("androidx.compose.material:material-icons-extended:$jetpackComposeVersion")
+ api("androidx.compose.runtime:runtime-livedata:$jetpackComposeVersion")
+ api("androidx.compose.ui:ui-tooling-preview:$jetpackComposeVersion")
+ api("androidx.lifecycle:lifecycle-livedata-ktx")
+ api("androidx.lifecycle:lifecycle-runtime-compose")
+ api("androidx.navigation:navigation-compose:2.8.0-alpha02")
+ api("com.github.PhilJay:MPAndroidChart:v3.1.0")
+ api("com.google.android.material:material:1.7.0-alpha03")
+ debugApi("androidx.compose.ui:ui-tooling:$jetpackComposeVersion")
+ implementation("com.airbnb.android:lottie-compose:5.2.0")
+
+// androidTestImplementation(project(":testutils"))
+// androidTestImplementation(libs.dexmaker.mockito)
+}
+
+tasks.register("coverageReport") {
+ group = "Reporting"
+ description = "Generate Jacoco coverage reports after running tests."
+ dependsOn("connectedDebugAndroidTest")
+ sourceDirectories.setFrom(files("src"))
+ classDirectories.setFrom(
+ fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
+ setExcludes(
+ listOf(
+ "com/android/settingslib/spa/debug/**",
+
+ // Excludes files forked from AndroidX.
+ "com/android/settingslib/spa/widget/scaffold/CustomizedAppBar*",
+ "com/android/settingslib/spa/widget/scaffold/TopAppBarColors*",
+
+ // Excludes files forked from Accompanist.
+ "com/android/settingslib/spa/framework/compose/DrawablePainter*",
+
+ // Excludes inline functions, which is not covered in Jacoco reports.
+ "com/android/settingslib/spa/framework/util/Collections*",
+ "com/android/settingslib/spa/framework/util/Flows*",
+
+ // Excludes debug functions
+ "com/android/settingslib/spa/framework/compose/TimeMeasurer*",
+
+ // Excludes slice demo presenter & provider
+ "com/android/settingslib/spa/slice/presenter/Demo*",
+ "com/android/settingslib/spa/slice/provider/Demo*",
+ )
+ )
+ }
+ )
+ executionData.setFrom(
+ fileTree(layout.buildDirectory.dir("outputs/code_coverage/debugAndroidTest/connected"))
+ )
+}
diff --git a/spa/res/values/themes.xml b/spa/res/values/themes.xml
new file mode 100644
index 00000000..b55dd1bc
--- /dev/null
+++ b/spa/res/values/themes.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/spa/src/com/android/settingslib/spa/SpaBaseDialogActivity.kt b/spa/src/com/android/settingslib/spa/SpaBaseDialogActivity.kt
new file mode 100644
index 00000000..dfb780af
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/SpaBaseDialogActivity.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+abstract class SpaBaseDialogActivity : ComponentActivity() {
+ private val spaEnvironment get() = SpaEnvironmentFactory.instance
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
+ setContent {
+ SettingsTheme {
+ Content()
+ }
+ }
+ }
+
+ @Composable
+ abstract fun Content()
+
+ companion object {
+ private const val TAG = "SpaBaseDialogActivity"
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/debug/DebugActivity.kt b/spa/src/com/android/settingslib/spa/debug/DebugActivity.kt
new file mode 100644
index 00000000..14af5084
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/DebugActivity.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.debug
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.android.settingslib.spa.R
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.SettingsEntry
+import com.android.settingslib.spa.framework.common.SettingsPage
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.compose.localNavController
+import com.android.settingslib.spa.framework.compose.navigator
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.SESSION_BROWSE
+import com.android.settingslib.spa.framework.util.SESSION_SEARCH
+import com.android.settingslib.spa.framework.util.createIntent
+import com.android.settingslib.spa.slice.fromEntry
+import com.android.settingslib.spa.slice.presenter.SliceDemo
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+import com.android.settingslib.spa.widget.scaffold.HomeScaffold
+import com.android.settingslib.spa.widget.scaffold.RegularScaffold
+
+private const val TAG = "DebugActivity"
+private const val ROUTE_ROOT = "root"
+private const val ROUTE_All_PAGES = "pages"
+private const val ROUTE_All_ENTRIES = "entries"
+private const val ROUTE_All_SLICES = "slices"
+private const val ROUTE_PAGE = "page"
+private const val ROUTE_ENTRY = "entry"
+private const val PARAM_NAME_PAGE_ID = "pid"
+private const val PARAM_NAME_ENTRY_ID = "eid"
+
+/**
+ * The Debug Activity to display all Spa Pages & Entries.
+ * One can open the debug activity by:
+ * $ adb shell am start -n /com.android.settingslib.spa.debug.DebugActivity
+ * For gallery, Package = com.android.settingslib.spa.gallery
+ */
+class DebugActivity : ComponentActivity() {
+ private val spaEnvironment get() = SpaEnvironmentFactory.instance
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setTheme(R.style.Theme_SpaLib)
+ super.onCreate(savedInstanceState)
+ spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
+
+ setContent {
+ SettingsTheme {
+ MainContent()
+ }
+ }
+ }
+
+ @Composable
+ private fun MainContent() {
+ val navController = rememberNavController()
+ CompositionLocalProvider(navController.localNavController()) {
+ NavHost(navController, ROUTE_ROOT) {
+ composable(route = ROUTE_ROOT) { RootPage() }
+ composable(route = ROUTE_All_PAGES) { AllPages() }
+ composable(route = ROUTE_All_ENTRIES) { AllEntries() }
+ composable(route = ROUTE_All_SLICES) { AllSlices() }
+ composable(
+ route = "$ROUTE_PAGE/{$PARAM_NAME_PAGE_ID}",
+ arguments = listOf(
+ navArgument(PARAM_NAME_PAGE_ID) { type = NavType.StringType },
+ )
+ ) { navBackStackEntry -> OnePage(navBackStackEntry.arguments) }
+ composable(
+ route = "$ROUTE_ENTRY/{$PARAM_NAME_ENTRY_ID}",
+ arguments = listOf(
+ navArgument(PARAM_NAME_ENTRY_ID) { type = NavType.StringType },
+ )
+ ) { navBackStackEntry -> OneEntry(navBackStackEntry.arguments) }
+ }
+ }
+ }
+
+ @Composable
+ fun RootPage() {
+ val entryRepository by spaEnvironment.entryRepository
+ val allPageWithEntry = remember { entryRepository.getAllPageWithEntry() }
+ val allEntry = remember { entryRepository.getAllEntries() }
+ val allSliceEntry =
+ remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } }
+ HomeScaffold(title = "Settings Debug") {
+ Preference(object : PreferenceModel {
+ override val title = "List All Pages (${allPageWithEntry.size})"
+ override val onClick = navigator(route = ROUTE_All_PAGES)
+ })
+ Preference(object : PreferenceModel {
+ override val title = "List All Entries (${allEntry.size})"
+ override val onClick = navigator(route = ROUTE_All_ENTRIES)
+ })
+ Preference(object : PreferenceModel {
+ override val title = "List All Slices (${allSliceEntry.size})"
+ override val onClick = navigator(route = ROUTE_All_SLICES)
+ })
+ }
+ }
+
+ @Composable
+ fun AllPages() {
+ val entryRepository by spaEnvironment.entryRepository
+ val allPageWithEntry = remember { entryRepository.getAllPageWithEntry() }
+ RegularScaffold(title = "All Pages (${allPageWithEntry.size})") {
+ for (pageWithEntry in allPageWithEntry) {
+ val page = pageWithEntry.page
+ Preference(object : PreferenceModel {
+ override val title = "${page.debugBrief()} (${pageWithEntry.entries.size})"
+ override val summary = { page.debugArguments() }
+ override val onClick = navigator(route = ROUTE_PAGE + "/${page.id}")
+ })
+ }
+ }
+ }
+
+ @Composable
+ fun AllEntries() {
+ val entryRepository by spaEnvironment.entryRepository
+ val allEntry = remember { entryRepository.getAllEntries() }
+ RegularScaffold(title = "All Entries (${allEntry.size})") {
+ EntryList(allEntry)
+ }
+ }
+
+ @Composable
+ fun AllSlices() {
+ val entryRepository by spaEnvironment.entryRepository
+ val authority = spaEnvironment.sliceProviderAuthorities
+ val allSliceEntry =
+ remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } }
+ RegularScaffold(title = "All Slices (${allSliceEntry.size})") {
+ for (entry in allSliceEntry) {
+ SliceDemo(sliceUri = Uri.Builder().fromEntry(entry, authority).build())
+ }
+ }
+ }
+
+ @Composable
+ fun OnePage(arguments: Bundle?) {
+ val entryRepository by spaEnvironment.entryRepository
+ val id = arguments!!.getString(PARAM_NAME_PAGE_ID, "")
+ val pageWithEntry = entryRepository.getPageWithEntry(id)!!
+ val page = pageWithEntry.page
+ RegularScaffold(title = "Page - ${page.debugBrief()}") {
+ Text(text = "id = ${page.id}")
+ Text(text = page.debugArguments())
+ Text(text = "enabled = ${page.isEnabled()}")
+ Text(text = "Entry size: ${pageWithEntry.entries.size}")
+ Preference(model = object : PreferenceModel {
+ override val title = "open page"
+ override val enabled = {
+ spaEnvironment.browseActivityClass != null && page.isBrowsable()
+ }
+ override val onClick = openPage(page)
+ })
+ EntryList(pageWithEntry.entries)
+ }
+ }
+
+ @Composable
+ fun OneEntry(arguments: Bundle?) {
+ val entryRepository by spaEnvironment.entryRepository
+ val id = arguments!!.getString(PARAM_NAME_ENTRY_ID, "")
+ val entry = entryRepository.getEntry(id)!!
+ val entryContent = remember { entry.debugContent(entryRepository) }
+ RegularScaffold(title = "Entry - ${entry.debugBrief()}") {
+ Preference(model = object : PreferenceModel {
+ override val title = "open entry"
+ override val enabled = {
+ spaEnvironment.browseActivityClass != null &&
+ entry.containerPage().isBrowsable()
+ }
+ override val onClick = openEntry(entry)
+ })
+ Text(text = entryContent)
+ }
+ }
+
+ @Composable
+ private fun EntryList(entries: Collection) {
+ for (entry in entries) {
+ Preference(object : PreferenceModel {
+ override val title = entry.debugBrief()
+ override val summary = {
+ "${entry.fromPage?.displayName} -> ${entry.toPage?.displayName}"
+ }
+ override val onClick = navigator(route = ROUTE_ENTRY + "/${entry.id}")
+ })
+ }
+ }
+
+ @Composable
+ private fun openPage(page: SettingsPage): (() -> Unit)? {
+ val context = LocalContext.current
+ val intent =
+ page.createIntent(SESSION_BROWSE) ?: return null
+ val route = page.buildRoute()
+ return {
+ spaEnvironment.logger.message(
+ TAG, "OpenPage: $route", category = LogCategory.FRAMEWORK
+ )
+ context.startActivity(intent)
+ }
+ }
+
+ @Composable
+ private fun openEntry(entry: SettingsEntry): (() -> Unit)? {
+ val context = LocalContext.current
+ val intent = entry.createIntent(SESSION_SEARCH)
+ ?: return null
+ val route = entry.containerPage().buildRoute()
+ return {
+ spaEnvironment.logger.message(
+ TAG, "OpenEntry: $route", category = LogCategory.FRAMEWORK
+ )
+ context.startActivity(intent)
+ }
+ }
+}
+
+/**
+ * A blank activity without any page.
+ */
+class BlankActivity : ComponentActivity()
diff --git a/spa/src/com/android/settingslib/spa/debug/DebugFormat.kt b/spa/src/com/android/settingslib/spa/debug/DebugFormat.kt
new file mode 100644
index 00000000..444a3f0f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/DebugFormat.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.debug
+
+import com.android.settingslib.spa.framework.common.EntrySearchData
+import com.android.settingslib.spa.framework.common.EntryStatusData
+import com.android.settingslib.spa.framework.common.SettingsEntry
+import com.android.settingslib.spa.framework.common.SettingsEntryRepository
+import com.android.settingslib.spa.framework.common.SettingsPage
+import com.android.settingslib.spa.framework.util.normalize
+
+private fun EntrySearchData.debugContent(): String {
+ val content = listOf(
+ "search_title = $title",
+ "search_keyword = $keyword",
+ )
+ return content.joinToString("\n")
+}
+
+private fun EntryStatusData.debugContent(): String {
+ val content = listOf(
+ "is_disabled = $isDisabled",
+ "is_switch_off = $isSwitchOff",
+ )
+ return content.joinToString("\n")
+}
+
+fun SettingsPage.debugArguments(): String {
+ val normArguments = parameter.normalize(arguments, eraseRuntimeValues = true)
+ if (normArguments == null || normArguments.isEmpty) return "[No arguments]"
+ return normArguments.toString().removeRange(0, 6)
+}
+
+fun SettingsPage.debugBrief(): String {
+ return displayName
+}
+
+fun SettingsEntry.debugBrief(): String {
+ return "${owner.displayName}:$label"
+}
+
+fun SettingsEntry.debugContent(entryRepository: SettingsEntryRepository): String {
+ val searchData = getSearchData()
+ val statusData = getStatusData()
+ val entryPathWithLabel = entryRepository.getEntryPathWithLabel(id)
+ val entryPathWithTitle = entryRepository.getEntryPathWithTitle(id,
+ searchData?.title ?: label)
+ val content = listOf(
+ "------ STATIC ------",
+ "id = $id",
+ "owner = ${owner.debugBrief()} ${owner.debugArguments()}",
+ "linkFrom = ${fromPage?.debugBrief()} ${fromPage?.debugArguments()}",
+ "linkTo = ${toPage?.debugBrief()} ${toPage?.debugArguments()}",
+ "hierarchy_path = $entryPathWithLabel",
+ "------ ATTRIBUTION ------",
+ "allowSearch = $isAllowSearch",
+ "isSearchDynamic = $isSearchDataDynamic",
+ "isSearchMutable = $hasMutableStatus",
+ "hasSlice = $hasSliceSupport",
+ "------ SEARCH ------",
+ "search_path = $entryPathWithTitle",
+ searchData?.debugContent() ?: "no search data",
+ statusData?.debugContent() ?: "no status data",
+ )
+ return content.joinToString("\n")
+}
diff --git a/spa/src/com/android/settingslib/spa/debug/DebugLogger.kt b/spa/src/com/android/settingslib/spa/debug/DebugLogger.kt
new file mode 100644
index 00000000..7d483363
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/DebugLogger.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.debug
+
+import android.os.Bundle
+import android.util.Log
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.LogEvent
+import com.android.settingslib.spa.framework.common.SpaLogger
+
+class DebugLogger : SpaLogger {
+ override fun message(tag: String, msg: String, category: LogCategory) {
+ Log.d("SpaMsg-$category", "[$tag] $msg")
+ }
+
+ override fun event(id: String, event: LogEvent, category: LogCategory, extraData: Bundle) {
+ val extraMsg = extraData.toString().removeRange(0, 6)
+ Log.d("SpaEvent-$category", "[$id] $event $extraMsg")
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/debug/DebugProvider.kt b/spa/src/com/android/settingslib/spa/debug/DebugProvider.kt
new file mode 100644
index 00000000..1fcc8885
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/DebugProvider.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.debug
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.URI_INTENT_SCHEME
+import android.content.UriMatcher
+import android.content.pm.ProviderInfo
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.util.Log
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.util.KEY_DESTINATION
+import com.android.settingslib.spa.framework.util.KEY_HIGHLIGHT_ENTRY
+import com.android.settingslib.spa.framework.util.KEY_SESSION_SOURCE_NAME
+import com.android.settingslib.spa.framework.util.SESSION_BROWSE
+import com.android.settingslib.spa.framework.util.SESSION_SEARCH
+import com.android.settingslib.spa.framework.util.createIntent
+
+private const val TAG = "DebugProvider"
+
+/**
+ * The content provider to return debug data.
+ * One can query the provider result by:
+ * $ adb shell content query --uri content:///
+ * For gallery, AuthorityPath = com.android.spa.gallery.debug
+ * Some examples:
+ * $ adb shell content query --uri content:///page_debug
+ * $ adb shell content query --uri content:///entry_debug
+ * $ adb shell content query --uri content:///page_info
+ * $ adb shell content query --uri content:///entry_info
+ */
+class DebugProvider : ContentProvider() {
+ private val spaEnvironment get() = SpaEnvironmentFactory.instance
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ TODO("Implement this to handle requests to delete one or more rows")
+ }
+
+ override fun getType(uri: Uri): String? {
+ TODO(
+ "Implement this to handle requests for the MIME type of the data" +
+ "at the given URI"
+ )
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ TODO("Implement this to handle requests to insert a new row.")
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ TODO("Implement this to handle requests to update one or more rows.")
+ }
+
+ override fun onCreate(): Boolean {
+ Log.d(TAG, "onCreate")
+ return true
+ }
+
+ override fun attachInfo(context: Context?, info: ProviderInfo?) {
+ if (info != null) {
+ QueryEnum.PAGE_DEBUG_QUERY.addUri(uriMatcher, info.authority)
+ QueryEnum.ENTRY_DEBUG_QUERY.addUri(uriMatcher, info.authority)
+ QueryEnum.PAGE_INFO_QUERY.addUri(uriMatcher, info.authority)
+ QueryEnum.ENTRY_INFO_QUERY.addUri(uriMatcher, info.authority)
+ }
+ super.attachInfo(context, info)
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ return try {
+ when (uriMatcher.match(uri)) {
+ QueryEnum.PAGE_DEBUG_QUERY.queryMatchCode -> queryPageDebug()
+ QueryEnum.ENTRY_DEBUG_QUERY.queryMatchCode -> queryEntryDebug()
+ QueryEnum.PAGE_INFO_QUERY.queryMatchCode -> queryPageInfo()
+ QueryEnum.ENTRY_INFO_QUERY.queryMatchCode -> queryEntryInfo()
+ else -> throw UnsupportedOperationException("Unknown Uri $uri")
+ }
+ } catch (e: UnsupportedOperationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Provider querying exception:", e)
+ null
+ }
+ }
+
+ private fun queryPageDebug(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.PAGE_DEBUG_QUERY.getColumns())
+ for (pageWithEntry in entryRepository.getAllPageWithEntry()) {
+ val page = pageWithEntry.page
+ if (!page.isBrowsable()) continue
+ val command = createBrowseAdbCommand(
+ destination = page.buildRoute(),
+ sessionName = SESSION_BROWSE
+ )
+ if (command != null) {
+ cursor.newRow().add(ColumnEnum.PAGE_START_ADB.id, command)
+ }
+ }
+ return cursor
+ }
+
+ private fun queryEntryDebug(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.ENTRY_DEBUG_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ val page = entry.containerPage()
+ if (!page.isBrowsable()) continue
+ val command = createBrowseAdbCommand(
+ destination = page.buildRoute(),
+ entryId = entry.id,
+ sessionName = SESSION_SEARCH
+ )
+ if (command != null) {
+ cursor.newRow().add(ColumnEnum.ENTRY_START_ADB.id, command)
+ }
+ }
+ return cursor
+ }
+
+ private fun queryPageInfo(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.PAGE_INFO_QUERY.getColumns())
+ for (pageWithEntry in entryRepository.getAllPageWithEntry()) {
+ val page = pageWithEntry.page
+ val intent = page.createIntent(SESSION_BROWSE) ?: Intent()
+ cursor.newRow()
+ .add(ColumnEnum.PAGE_ID.id, page.id)
+ .add(ColumnEnum.PAGE_NAME.id, page.displayName)
+ .add(ColumnEnum.PAGE_ROUTE.id, page.buildRoute())
+ .add(ColumnEnum.PAGE_INTENT_URI.id, intent.toUri(URI_INTENT_SCHEME))
+ .add(ColumnEnum.PAGE_ENTRY_COUNT.id, pageWithEntry.entries.size)
+ .add(ColumnEnum.PAGE_BROWSABLE.id, if (page.isBrowsable()) 1 else 0)
+ }
+ return cursor
+ }
+
+ private fun queryEntryInfo(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.ENTRY_INFO_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ val intent = entry.createIntent(SESSION_SEARCH) ?: Intent()
+ cursor.newRow()
+ .add(ColumnEnum.ENTRY_ID.id, entry.id)
+ .add(ColumnEnum.ENTRY_LABEL.id, entry.label)
+ .add(ColumnEnum.ENTRY_ROUTE.id, entry.containerPage().buildRoute())
+ .add(ColumnEnum.ENTRY_INTENT_URI.id, intent.toUri(URI_INTENT_SCHEME))
+ .add(
+ ColumnEnum.ENTRY_HIERARCHY_PATH.id,
+ entryRepository.getEntryPathWithLabel(entry.id)
+ )
+ }
+ return cursor
+ }
+}
+
+private fun createBrowseAdbCommand(
+ destination: String? = null,
+ entryId: String? = null,
+ sessionName: String? = null,
+): String? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
+ val packageName = context.packageName
+ val activityName = browseActivityClass.name.replace(packageName, "")
+ val destinationParam =
+ if (destination != null) " -e $KEY_DESTINATION $destination" else ""
+ val highlightParam =
+ if (entryId != null) " -e $KEY_HIGHLIGHT_ENTRY $entryId" else ""
+ val sessionParam =
+ if (sessionName != null) " -e $KEY_SESSION_SOURCE_NAME $sessionName" else ""
+ return "adb shell am start -n $packageName/$activityName" +
+ "$destinationParam$highlightParam$sessionParam"
+}
diff --git a/spa/src/com/android/settingslib/spa/debug/ProviderColumn.kt b/spa/src/com/android/settingslib/spa/debug/ProviderColumn.kt
new file mode 100644
index 00000000..9b46ec29
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/ProviderColumn.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.debug
+
+import android.content.UriMatcher
+
+/**
+ * Enum to define all column names in provider.
+ */
+enum class ColumnEnum(val id: String) {
+ // Columns related to page
+ PAGE_ID("pageId"),
+ PAGE_NAME("pageName"),
+ PAGE_ROUTE("pageRoute"),
+ PAGE_INTENT_URI("pageIntent"),
+ PAGE_ENTRY_COUNT("entryCount"),
+ PAGE_BROWSABLE("pageBrowsable"),
+ PAGE_START_ADB("pageStartAdb"),
+
+ // Columns related to entry
+ ENTRY_ID("entryId"),
+ ENTRY_LABEL("entryLabel"),
+ ENTRY_ROUTE("entryRoute"),
+ ENTRY_INTENT_URI("entryIntent"),
+ ENTRY_HIERARCHY_PATH("entryPath"),
+ ENTRY_START_ADB("entryStartAdb"),
+}
+
+/**
+ * Enum to define all queries supported in the provider.
+ */
+enum class QueryEnum(
+ val queryPath: String,
+ val queryMatchCode: Int,
+ val columnNames: List
+) {
+ // For debug
+ PAGE_DEBUG_QUERY(
+ "page_debug", 1,
+ listOf(ColumnEnum.PAGE_START_ADB)
+ ),
+ ENTRY_DEBUG_QUERY(
+ "entry_debug", 2,
+ listOf(ColumnEnum.ENTRY_START_ADB)
+ ),
+
+ // page related queries.
+ PAGE_INFO_QUERY(
+ "page_info", 100,
+ listOf(
+ ColumnEnum.PAGE_ID,
+ ColumnEnum.PAGE_NAME,
+ ColumnEnum.PAGE_ROUTE,
+ ColumnEnum.PAGE_INTENT_URI,
+ ColumnEnum.PAGE_ENTRY_COUNT,
+ ColumnEnum.PAGE_BROWSABLE,
+ )
+ ),
+
+ // entry related queries
+ ENTRY_INFO_QUERY(
+ "entry_info", 200,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.ENTRY_ROUTE,
+ ColumnEnum.ENTRY_INTENT_URI,
+ ColumnEnum.ENTRY_HIERARCHY_PATH,
+ )
+ ),
+}
+
+internal fun QueryEnum.getColumns(): Array {
+ return columnNames.map { it.id }.toTypedArray()
+}
+
+internal fun QueryEnum.getIndex(name: ColumnEnum): Int {
+ return columnNames.indexOf(name)
+}
+
+internal fun QueryEnum.addUri(uriMatcher: UriMatcher, authority: String) {
+ uriMatcher.addURI(authority, queryPath, queryMatchCode)
+}
diff --git a/spa/src/com/android/settingslib/spa/debug/UiModePreviews.kt b/spa/src/com/android/settingslib/spa/debug/UiModePreviews.kt
new file mode 100644
index 00000000..d48e5646
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/debug/UiModePreviews.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.debug
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+annotation class UiModePreviews
diff --git a/spa/src/com/android/settingslib/spa/framework/BrowseActivity.kt b/spa/src/com/android/settingslib/spa/framework/BrowseActivity.kt
new file mode 100644
index 00000000..da1ee77b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/BrowseActivity.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.annotation.VisibleForTesting
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.dialog
+import androidx.navigation.compose.rememberNavController
+import com.android.settingslib.spa.R
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.NullPageProvider
+import com.android.settingslib.spa.framework.common.SettingsPage
+import com.android.settingslib.spa.framework.common.SettingsPageProvider
+import com.android.settingslib.spa.framework.common.SettingsPageProvider.NavType
+import com.android.settingslib.spa.framework.common.SettingsPageProviderRepository
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.common.createSettingsPage
+import com.android.settingslib.spa.framework.compose.LocalNavController
+import com.android.settingslib.spa.framework.compose.NavControllerWrapperImpl
+import com.android.settingslib.spa.framework.compose.animatedComposable
+import com.android.settingslib.spa.framework.compose.localNavController
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.PageLogger
+import com.android.settingslib.spa.framework.util.getDestination
+import com.android.settingslib.spa.framework.util.getEntryId
+import com.android.settingslib.spa.framework.util.getSessionName
+import com.android.settingslib.spa.framework.util.navRoute
+
+private const val TAG = "BrowseActivity"
+
+/**
+ * The Activity to render ALL SPA pages, and handles jumps between SPA pages.
+ *
+ * One can open any SPA page by:
+ * ```
+ * $ adb shell am start -n -e spaActivityDestination
+ * ```
+ * - For Gallery, BrowseActivityComponent = com.android.settingslib.spa.gallery/.GalleryMainActivity
+ * - For Settings, BrowseActivityComponent = com.android.settings/.spa.SpaActivity
+ *
+ * Some examples:
+ * ```
+ * $ adb shell am start -n -e spaActivityDestination HOME
+ * $ adb shell am start -n -e spaActivityDestination ARGUMENT/bar/5
+ * ```
+ */
+open class BrowseActivity : ComponentActivity() {
+ private val spaEnvironment get() = SpaEnvironmentFactory.instance
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setTheme(R.style.Theme_SpaLib)
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
+
+ setContent {
+ SettingsTheme {
+ val sppRepository by spaEnvironment.pageProviderRepository
+ BrowseContent(
+ sppRepository = sppRepository,
+ isPageEnabled = ::isPageEnabled,
+ initialIntent = intent,
+ )
+ }
+ }
+ }
+
+ open fun isPageEnabled(page: SettingsPage) = page.isEnabled()
+}
+
+@VisibleForTesting
+@Composable
+internal fun BrowseContent(
+ sppRepository: SettingsPageProviderRepository,
+ isPageEnabled: (SettingsPage) -> Boolean,
+ initialIntent: Intent?,
+) {
+ val navController = rememberNavController()
+ CompositionLocalProvider(navController.localNavController()) {
+ val controller = LocalNavController.current as NavControllerWrapperImpl
+ controller.NavContent(sppRepository.getAllProviders()) { page ->
+ if (remember { isPageEnabled(page) }) {
+ LaunchedEffect(Unit) {
+ Log.d(TAG, "Launching page ${page.sppName}")
+ }
+ page.PageLogger()
+ page.UiLayout()
+ } else {
+ LaunchedEffect(Unit) {
+ controller.navigateBack()
+ }
+ }
+ }
+ controller.InitialDestination(initialIntent, sppRepository.getDefaultStartPage())
+ }
+}
+
+@Composable
+private fun NavControllerWrapperImpl.NavContent(
+ allProvider: Collection,
+ content: @Composable (SettingsPage) -> Unit,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = NullPageProvider.name,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ composable(NullPageProvider.name) {}
+ for (spp in allProvider) {
+ destination(spp) { navBackStackEntry ->
+ val page = remember { spp.createSettingsPage(navBackStackEntry.arguments) }
+ content(page)
+ }
+ }
+ }
+}
+
+private fun NavGraphBuilder.destination(
+ spp: SettingsPageProvider,
+ content: @Composable (NavBackStackEntry) -> Unit,
+) {
+ val route = spp.name + spp.parameter.navRoute()
+ when (spp.navType) {
+ NavType.Page -> animatedComposable(route, spp.parameter) { content(it) }
+ NavType.Dialog -> dialog(route, spp.parameter) { content(it) }
+ }
+}
+
+@Composable
+private fun NavControllerWrapperImpl.InitialDestination(
+ initialIntent: Intent?,
+ defaultDestination: String
+) {
+ val destinationNavigated = rememberSaveable { mutableStateOf(false) }
+ if (destinationNavigated.value) return
+ destinationNavigated.value = true
+
+ val initialDestination = initialIntent?.getDestination() ?: defaultDestination
+ if (initialDestination.isEmpty()) return
+ val initialEntryId = initialIntent?.getEntryId()
+ val sessionSourceName = initialIntent?.getSessionName()
+
+ LaunchedEffect(Unit) {
+ highlightId = initialEntryId
+ sessionName = sessionSourceName
+ navController.navigate(initialDestination) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/EntryMacro.kt b/spa/src/com/android/settingslib/spa/framework/common/EntryMacro.kt
new file mode 100644
index 00000000..b3571a1f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/EntryMacro.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Defines interface of a entry macro, which contains entry functions to support different
+ * scenarios, such as browsing (UiLayout), search, etc.
+ */
+interface EntryMacro {
+ @Composable
+ fun UiLayout() {}
+ fun getSearchData(): EntrySearchData? = null
+ fun getStatusData(): EntryStatusData? = null
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/EntrySearchData.kt b/spa/src/com/android/settingslib/spa/framework/common/EntrySearchData.kt
new file mode 100644
index 00000000..9bc620f9
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/EntrySearchData.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+/**
+ * Defines Search data of one Settings entry.
+ */
+data class EntrySearchData(
+ val title: String = "",
+ val keyword: List = emptyList(),
+)
diff --git a/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt b/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt
new file mode 100644
index 00000000..fc551a88
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import androidx.lifecycle.LiveData
+import androidx.slice.Slice
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+open class EntrySliceData : LiveData() {
+ private val asyncRunnerScope = CoroutineScope(Dispatchers.IO)
+ private var asyncRunnerJob: Job? = null
+ private var asyncActionJob: Job? = null
+ private var isActive = false
+
+ open suspend fun asyncRunner() {}
+
+ open suspend fun asyncAction() {}
+
+ override fun onActive() {
+ asyncRunnerJob?.cancel()
+ asyncRunnerJob = asyncRunnerScope.launch { asyncRunner() }
+ isActive = true
+ }
+
+ override fun onInactive() {
+ asyncRunnerJob?.cancel()
+ asyncRunnerJob = null
+ asyncActionJob?.cancel()
+ asyncActionJob = null
+ isActive = false
+ }
+
+ fun isActive(): Boolean {
+ return isActive
+ }
+
+ fun doAction() {
+ asyncActionJob?.cancel()
+ asyncActionJob = asyncRunnerScope.launch { asyncAction() }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/EntryStatusData.kt b/spa/src/com/android/settingslib/spa/framework/common/EntryStatusData.kt
new file mode 100644
index 00000000..3e9dd3be
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/EntryStatusData.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+/**
+ * Defines the status data of one Settings entry, which could be changed frequently.
+ */
+data class EntryStatusData(
+ val isDisabled: Boolean = false,
+ val isSwitchOff: Boolean = false,
+)
diff --git a/spa/src/com/android/settingslib/spa/framework/common/PageModel.kt b/spa/src/com/android/settingslib/spa/framework/common/PageModel.kt
new file mode 100644
index 00000000..edcca180
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/PageModel.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.os.Bundle
+import androidx.lifecycle.ViewModel
+
+open class PageModel : ViewModel() {
+ var initialized = false
+
+ fun initOnce(arguments: Bundle? = null) {
+ // Initialize only once
+ if (initialized) return
+ initialized = true
+ initialize(arguments)
+ }
+
+ open fun initialize(arguments: Bundle?) {}
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt
new file mode 100644
index 00000000..90581b99
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ProvidedValue
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import com.android.settingslib.spa.framework.compose.LocalNavController
+
+interface EntryData {
+ val pageId: String?
+ get() = null
+ val entryId: String?
+ get() = null
+ val isHighlighted: Boolean
+ get() = false
+ val arguments: Bundle?
+ get() = null
+}
+
+val LocalEntryDataProvider =
+ compositionLocalOf { object : EntryData {} }
+
+typealias UiLayerRenderer = @Composable (arguments: Bundle?) -> Unit
+typealias StatusDataGetter = (arguments: Bundle?) -> EntryStatusData?
+typealias SearchDataGetter = (arguments: Bundle?) -> EntrySearchData?
+typealias SliceDataGetter = (sliceUri: Uri, arguments: Bundle?) -> EntrySliceData?
+
+/**
+ * Defines data of a Settings entry.
+ */
+data class SettingsEntry(
+ // The unique id of this entry, which is computed by name + owner + fromPage + toPage.
+ val id: String,
+
+ // The name of the entry, which is used to compute the unique id, and need to be stable.
+ private val name: String,
+
+ // The label of the entry, for better readability.
+ // For migration mapping, this should match the android:key field in the old architecture
+ // if applicable.
+ val label: String,
+
+ // The owner page of this entry.
+ val owner: SettingsPage,
+
+ // Defines linking of Settings entries
+ val fromPage: SettingsPage? = null,
+ val toPage: SettingsPage? = null,
+
+ /**
+ * ========================================
+ * Defines entry attributes here.
+ * ========================================
+ */
+ val isAllowSearch: Boolean = false,
+
+ // Indicate whether the search indexing data of entry is dynamic.
+ val isSearchDataDynamic: Boolean = false,
+
+ // Indicate whether the status of entry is mutable.
+ // If so, for instance, we'll reindex its status for search.
+ val hasMutableStatus: Boolean = false,
+
+ // Indicate whether the entry has SliceProvider support.
+ val hasSliceSupport: Boolean = false,
+
+ /**
+ * ========================================
+ * Defines entry APIs to get data here.
+ * ========================================
+ */
+
+ /**
+ * API to get the status data of the entry, such as isDisabled / isSwitchOff.
+ * Returns null if this entry do NOT have any status.
+ */
+ private val statusDataImpl: StatusDataGetter = { null },
+
+ /**
+ * API to get Search indexing data for this entry, such as title / keyword.
+ * Returns null if this entry do NOT support search.
+ */
+ private val searchDataImpl: SearchDataGetter = { null },
+
+ /**
+ * API to get Slice data of this entry. The Slice data is implemented as a LiveData,
+ * and is associated with the Slice's lifecycle (pin / unpin) by the framework.
+ */
+ private val sliceDataImpl: SliceDataGetter = { _: Uri, _: Bundle? -> null },
+
+ /**
+ * API to Render UI of this entry directly. For now, we use it in the internal injection, to
+ * support the case that the injection page owner wants to maintain both data and UI of the
+ * injected entry. In the long term, we may deprecate the @Composable Page() API in SPP, and
+ * use each entries' UI rendering function in the page instead.
+ */
+ private val uiLayoutImpl: UiLayerRenderer = {},
+) {
+ fun containerPage(): SettingsPage {
+ // The Container page of the entry, which is the from-page or
+ // the owner-page if from-page is unset.
+ return fromPage ?: owner
+ }
+
+ private fun fullArgument(runtimeArguments: Bundle? = null): Bundle {
+ return Bundle().apply {
+ if (owner.arguments != null) putAll(owner.arguments)
+ // Put runtime args later, which can override page args.
+ if (runtimeArguments != null) putAll(runtimeArguments)
+ }
+ }
+
+ fun getStatusData(runtimeArguments: Bundle? = null): EntryStatusData? {
+ return statusDataImpl(fullArgument(runtimeArguments))
+ }
+
+ fun getSearchData(runtimeArguments: Bundle? = null): EntrySearchData? {
+ return searchDataImpl(fullArgument(runtimeArguments))
+ }
+
+ fun getSliceData(sliceUri: Uri, runtimeArguments: Bundle? = null): EntrySliceData? {
+ return sliceDataImpl(sliceUri, fullArgument(runtimeArguments))
+ }
+
+ @Composable
+ fun UiLayout(runtimeArguments: Bundle? = null) {
+ val arguments = remember { fullArgument(runtimeArguments) }
+ CompositionLocalProvider(provideLocalEntryData(arguments)) {
+ uiLayoutImpl(arguments)
+ }
+ }
+
+ @Composable
+ private fun provideLocalEntryData(arguments: Bundle): ProvidedValue {
+ val controller = LocalNavController.current
+ return LocalEntryDataProvider provides remember {
+ object : EntryData {
+ override val pageId = containerPage().id
+ override val entryId = id
+ override val isHighlighted = controller.highlightEntryId == id
+ override val arguments = arguments
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryBuilder.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryBuilder.kt
new file mode 100644
index 00000000..0d489e89
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryBuilder.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.common
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.compose.runtime.remember
+import com.android.settingslib.spa.framework.util.genEntryId
+
+private const val INJECT_ENTRY_LABEL = "INJECT"
+private const val ROOT_ENTRY_LABEL = "ROOT"
+
+/**
+ * The helper to build a Settings Entry instance.
+ */
+class SettingsEntryBuilder(private val name: String, private val owner: SettingsPage) {
+ private var label = name
+ private var fromPage: SettingsPage? = null
+ private var toPage: SettingsPage? = null
+
+ // Attributes
+ private var isAllowSearch: Boolean = false
+ private var isSearchDataDynamic: Boolean = false
+ private var hasMutableStatus: Boolean = false
+ private var hasSliceSupport: Boolean = false
+
+ // Functions
+ private var uiLayoutFn: UiLayerRenderer = { }
+ private var statusDataFn: StatusDataGetter = { null }
+ private var searchDataFn: SearchDataGetter = { null }
+ private var sliceDataFn: SliceDataGetter = { _: Uri, _: Bundle? -> null }
+
+ fun build(): SettingsEntry {
+ val page = fromPage ?: owner
+ val isEnabled = page.isEnabled()
+ return SettingsEntry(
+ id = genEntryId(name, owner, fromPage, toPage),
+ name = name,
+ owner = owner,
+ label = label,
+
+ // linking data
+ fromPage = fromPage,
+ toPage = toPage,
+
+ // attributes
+ // TODO: set isEnabled & (isAllowSearch, hasSliceSupport) separately
+ isAllowSearch = isEnabled && isAllowSearch,
+ isSearchDataDynamic = isSearchDataDynamic,
+ hasMutableStatus = hasMutableStatus,
+ hasSliceSupport = isEnabled && hasSliceSupport,
+
+ // functions
+ statusDataImpl = statusDataFn,
+ searchDataImpl = searchDataFn,
+ sliceDataImpl = sliceDataFn,
+ uiLayoutImpl = uiLayoutFn,
+ )
+ }
+
+ fun setLabel(label: String): SettingsEntryBuilder {
+ this.label = label
+ return this
+ }
+
+ fun setLink(
+ fromPage: SettingsPage? = null,
+ toPage: SettingsPage? = null
+ ): SettingsEntryBuilder {
+ if (fromPage != null) this.fromPage = fromPage
+ if (toPage != null) this.toPage = toPage
+ return this
+ }
+
+ fun setIsSearchDataDynamic(isDynamic: Boolean): SettingsEntryBuilder {
+ this.isSearchDataDynamic = isDynamic
+ return this
+ }
+
+ fun setHasMutableStatus(hasMutableStatus: Boolean): SettingsEntryBuilder {
+ this.hasMutableStatus = hasMutableStatus
+ return this
+ }
+
+ fun setMacro(fn: (arguments: Bundle?) -> EntryMacro): SettingsEntryBuilder {
+ setStatusDataFn { fn(it).getStatusData() }
+ setSearchDataFn { fn(it).getSearchData() }
+ setUiLayoutFn {
+ val macro = remember { fn(it) }
+ macro.UiLayout()
+ }
+ return this
+ }
+
+ fun setStatusDataFn(fn: StatusDataGetter): SettingsEntryBuilder {
+ this.statusDataFn = fn
+ return this
+ }
+
+ fun setSearchDataFn(fn: SearchDataGetter): SettingsEntryBuilder {
+ this.searchDataFn = fn
+ this.isAllowSearch = true
+ return this
+ }
+
+ fun clearSearchDataFn(): SettingsEntryBuilder {
+ this.searchDataFn = { null }
+ this.isAllowSearch = false
+ return this
+ }
+
+ fun setSliceDataFn(fn: SliceDataGetter): SettingsEntryBuilder {
+ this.sliceDataFn = fn
+ this.hasSliceSupport = true
+ return this
+ }
+
+ fun setUiLayoutFn(fn: UiLayerRenderer): SettingsEntryBuilder {
+ this.uiLayoutFn = fn
+ return this
+ }
+
+ companion object {
+ fun create(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
+ return SettingsEntryBuilder(entryName, owner)
+ }
+
+ fun createLinkFrom(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
+ return create(entryName, owner).setLink(fromPage = owner)
+ }
+
+ fun createLinkTo(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
+ return create(entryName, owner).setLink(toPage = owner)
+ }
+
+ fun create(
+ owner: SettingsPage,
+ entryName: String,
+ label: String = entryName,
+ ): SettingsEntryBuilder = SettingsEntryBuilder(entryName, owner).setLabel(label)
+
+ fun createInject(
+ owner: SettingsPage,
+ label: String = "${INJECT_ENTRY_LABEL}_${owner.displayName}",
+ ): SettingsEntryBuilder = createLinkTo(INJECT_ENTRY_LABEL, owner).setLabel(label)
+
+ fun createRoot(
+ owner: SettingsPage,
+ label: String = "${ROOT_ENTRY_LABEL}_${owner.displayName}",
+ ): SettingsEntryBuilder = createLinkTo(ROOT_ENTRY_LABEL, owner).setLabel(label)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryRepository.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryRepository.kt
new file mode 100644
index 00000000..8811b946
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsEntryRepository.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.util.Log
+import java.util.LinkedList
+
+private const val TAG = "EntryRepository"
+private const val MAX_ENTRY_SIZE = 5000
+private const val MAX_ENTRY_DEPTH = 10
+
+data class SettingsPageWithEntry(
+ val page: SettingsPage,
+ val entries: List,
+ // The inject entry, which to-page is current page.
+ val injectEntry: SettingsEntry,
+)
+
+/**
+ * The repository to maintain all Settings entries
+ */
+class SettingsEntryRepository(sppRepository: SettingsPageProviderRepository) {
+ // Map of entry unique Id to entry
+ private val entryMap: Map
+
+ // Map of Settings page to its contained entries.
+ private val pageWithEntryMap: Map
+
+ init {
+ Log.d(TAG, "Initialize")
+ entryMap = mutableMapOf()
+ pageWithEntryMap = mutableMapOf()
+
+ val nullPage = NullPageProvider.createSettingsPage()
+ val entryQueue = LinkedList()
+ for (page in sppRepository.getAllRootPages()) {
+ val rootEntry =
+ SettingsEntryBuilder.createRoot(owner = page).setLink(fromPage = nullPage).build()
+ if (!entryMap.containsKey(rootEntry.id)) {
+ entryQueue.push(rootEntry)
+ entryMap.put(rootEntry.id, rootEntry)
+ }
+ }
+
+ while (entryQueue.isNotEmpty() && entryMap.size < MAX_ENTRY_SIZE) {
+ val entry = entryQueue.pop()
+ val page = entry.toPage
+ if (page == null || pageWithEntryMap.containsKey(page.id)) continue
+ val spp = sppRepository.getProviderOrNull(page.sppName) ?: continue
+ val newEntries = spp.buildEntry(page.arguments)
+ // The page id could be existed already, if there are 2+ pages go to the same one.
+ // For now, override the previous ones, which means only the last from-page is kept.
+ // TODO: support multiple from-pages if necessary.
+ pageWithEntryMap[page.id] = SettingsPageWithEntry(
+ page = page,
+ entries = newEntries,
+ injectEntry = entry
+ )
+ for (newEntry in newEntries) {
+ if (!entryMap.containsKey(newEntry.id)) {
+ entryQueue.push(newEntry)
+ entryMap.put(newEntry.id, newEntry)
+ }
+ }
+ }
+
+ Log.d(
+ TAG,
+ "Initialize Completed: ${entryMap.size} entries in ${pageWithEntryMap.size} pages"
+ )
+ }
+
+ fun getAllPageWithEntry(): Collection {
+ return pageWithEntryMap.values
+ }
+
+ fun getPageWithEntry(pageId: String): SettingsPageWithEntry? {
+ return pageWithEntryMap[pageId]
+ }
+
+ fun getAllEntries(): Collection {
+ return entryMap.values
+ }
+
+ fun getEntry(entryId: String): SettingsEntry? {
+ return entryMap[entryId]
+ }
+
+ private fun getEntryPath(entryId: String): List {
+ val entryPath = ArrayList()
+ var currentEntry = entryMap[entryId]
+ while (currentEntry != null && entryPath.size < MAX_ENTRY_DEPTH) {
+ entryPath.add(currentEntry)
+ val currentPage = currentEntry.containerPage()
+ currentEntry = pageWithEntryMap[currentPage.id]?.injectEntry
+ }
+ return entryPath
+ }
+
+ fun getEntryPathWithLabel(entryId: String): List {
+ val entryPath = getEntryPath(entryId)
+ return entryPath.map { it.label }
+ }
+
+ fun getEntryPathWithTitle(entryId: String, defaultTitle: String): List {
+ val entryPath = getEntryPath(entryId)
+ return entryPath.map {
+ if (it.toPage == null)
+ defaultTitle
+ else {
+ it.toPage.getTitle()
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt
new file mode 100644
index 00000000..3fdb1d14
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.navigation.NamedNavArgument
+import com.android.settingslib.spa.framework.util.genPageId
+import com.android.settingslib.spa.framework.util.isRuntimeParam
+import com.android.settingslib.spa.framework.util.navLink
+
+/**
+ * Defines data to identify a Settings page.
+ */
+data class SettingsPage(
+ // The unique id of this page, which is computed by sppName + normalized(arguments)
+ val id: String,
+
+ // The name of the page provider, who creates this page. It is used to compute the unique id.
+ val sppName: String,
+
+ // The display name of the page, for better readability.
+ val displayName: String,
+
+ // The parameters defined in its page provider.
+ val parameter: List = emptyList(),
+
+ // The arguments of this page.
+ val arguments: Bundle? = null,
+) {
+ companion object {
+ // TODO: cleanup it once all its usage in Settings are switched to Spp.createSettingsPage
+ fun create(
+ name: String,
+ displayName: String? = null,
+ parameter: List = emptyList(),
+ arguments: Bundle? = null
+ ): SettingsPage {
+ return SettingsPage(
+ id = genPageId(name, parameter, arguments),
+ sppName = name,
+ displayName = displayName ?: name,
+ parameter = parameter,
+ arguments = arguments
+ )
+ }
+ }
+
+ // Returns if this Settings Page is created by the given Spp.
+ fun isCreateBy(SppName: String): Boolean {
+ return sppName == SppName
+ }
+
+ fun buildRoute(): String {
+ return sppName + parameter.navLink(arguments)
+ }
+
+ fun isBrowsable(): Boolean {
+ if (sppName == NullPageProvider.name) return false
+ for (navArg in parameter) {
+ if (navArg.isRuntimeParam()) return false
+ }
+ return true
+ }
+
+ fun isEnabled(): Boolean =
+ SpaEnvironment.IS_DEBUG || getPageProvider(sppName)?.isEnabled(arguments) ?: false
+
+ fun getTitle(): String {
+ return getPageProvider(sppName)?.getTitle(arguments) ?: ""
+ }
+
+ @Composable
+ fun UiLayout() {
+ getPageProvider(sppName)?.Page(arguments)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProvider.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProvider.kt
new file mode 100644
index 00000000..0281ab81
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProvider.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.navigation.NamedNavArgument
+import com.android.settingslib.spa.framework.util.genPageId
+import com.android.settingslib.spa.framework.util.normalizeArgList
+import com.android.settingslib.spa.widget.scaffold.RegularScaffold
+
+private const val NULL_PAGE_NAME = "NULL"
+
+/**
+ * An SettingsPageProvider which is used to create Settings page instances.
+ */
+interface SettingsPageProvider {
+
+ /** The page provider name, needs to be *unique* and *stable*. */
+ val name: String
+
+ enum class NavType {
+ Page,
+ Dialog,
+ }
+
+ val navType: NavType
+ get() = NavType.Page
+
+ /** The display name of this page provider, for better readability. */
+ val displayName: String
+ get() = name
+
+ /** The page parameters, default is no parameters. */
+ val parameter: List
+ get() = emptyList()
+
+ /**
+ * The API to indicate whether the page is enabled or not.
+ * During SPA page migration, one can use it to enable certain pages in one release.
+ * When the page is disabled, all its related functionalities, such as browsing, search,
+ * slice provider, are disabled as well.
+ */
+ fun isEnabled(arguments: Bundle?): Boolean = true
+
+ fun getTitle(arguments: Bundle?): String = displayName
+
+ fun buildEntry(arguments: Bundle?): List = emptyList()
+
+ /** The [Composable] used to render this page. */
+ @Composable
+ fun Page(arguments: Bundle?) {
+ val title = remember { getTitle(arguments) }
+ val entries = remember { buildEntry(arguments) }
+ RegularScaffold(title) {
+ for (entry in entries) {
+ entry.UiLayout()
+ }
+ }
+ }
+}
+
+fun SettingsPageProvider.createSettingsPage(arguments: Bundle? = null): SettingsPage {
+ return SettingsPage(
+ id = genPageId(name, parameter, arguments),
+ sppName = name,
+ displayName = displayName + parameter.normalizeArgList(arguments, eraseRuntimeValues = true)
+ .joinToString("") { arg -> "/$arg" },
+ parameter = parameter,
+ arguments = arguments,
+ )
+}
+
+internal object NullPageProvider : SettingsPageProvider {
+ override val name = NULL_PAGE_NAME
+}
+
+fun getPageProvider(sppName: String): SettingsPageProvider? {
+ if (!SpaEnvironmentFactory.isReady()) return null
+ val pageProviderRepository by SpaEnvironmentFactory.instance.pageProviderRepository
+ return pageProviderRepository.getProviderOrNull(sppName)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProviderRepository.kt b/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProviderRepository.kt
new file mode 100644
index 00000000..5a5b411f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SettingsPageProviderRepository.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.util.Log
+
+private const val TAG = "SppRepository"
+
+class SettingsPageProviderRepository(
+ allPageProviders: List,
+ private val rootPages: List = emptyList(),
+) {
+ // Map of page name to its provider.
+ private val pageProviderMap: Map
+
+ init {
+ pageProviderMap = allPageProviders.associateBy { it.name }
+ Log.d(TAG, "Initialize Completed: ${pageProviderMap.size} spp")
+ }
+
+ fun getDefaultStartPage(): String {
+ return if (rootPages.isEmpty()) "" else rootPages[0].buildRoute()
+ }
+
+ fun getAllRootPages(): Collection {
+ return rootPages
+ }
+
+ fun getAllProviders(): Collection {
+ return pageProviderMap.values
+ }
+
+ fun getProviderOrNull(name: String): SettingsPageProvider? {
+ return pageProviderMap[name]
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt b/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt
new file mode 100644
index 00000000..2d956d5e
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import com.android.settingslib.spa.slice.SettingsSliceDataRepository
+
+private const val TAG = "SpaEnvironment"
+
+object SpaEnvironmentFactory {
+ private var spaEnvironment: SpaEnvironment? = null
+
+ fun reset() {
+ spaEnvironment = null
+ }
+
+ fun reset(env: SpaEnvironment) {
+ spaEnvironment = env
+ Log.d(TAG, "reset")
+ }
+
+ @Composable
+ fun resetForPreview() {
+ val context = LocalContext.current
+ spaEnvironment = object : SpaEnvironment(context) {
+ override val pageProviderRepository = lazy {
+ SettingsPageProviderRepository(
+ allPageProviders = emptyList(),
+ rootPages = emptyList()
+ )
+ }
+ }
+ Log.d(TAG, "resetForPreview")
+ }
+
+ fun isReady(): Boolean {
+ return spaEnvironment != null
+ }
+
+ val instance: SpaEnvironment
+ get() {
+ if (spaEnvironment == null)
+ throw UnsupportedOperationException("Spa environment is not set")
+ return spaEnvironment!!
+ }
+}
+
+abstract class SpaEnvironment(context: Context) {
+ abstract val pageProviderRepository: Lazy
+
+ val entryRepository = lazy { SettingsEntryRepository(pageProviderRepository.value) }
+
+ val sliceDataRepository = lazy { SettingsSliceDataRepository(entryRepository.value) }
+
+ // The application context. Use local context as fallback when applicationContext is not
+ // available (e.g. in Robolectric test).
+ val appContext: Context = context.applicationContext ?: context
+
+ // Set your SpaLogger implementation, for any SPA events logging.
+ open val logger: SpaLogger = object : SpaLogger {}
+
+ // Specify class name of browse activity and slice broadcast receiver, which is used to
+ // generate the necessary intents.
+ open val browseActivityClass: Class? = null
+ open val sliceBroadcastReceiverClass: Class? = null
+
+ // Specify provider authorities for debugging purpose.
+ open val searchProviderAuthorities: String? = null
+ open val sliceProviderAuthorities: String? = null
+
+ // TODO: add other environment setup here.
+ companion object {
+ /**
+ * Whether debug mode is on or off.
+ *
+ * If set to true, this will also enable all the pages under development (allows browsing
+ * and searching).
+ */
+ const val IS_DEBUG = false
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/common/SpaLogger.kt b/spa/src/com/android/settingslib/spa/framework/common/SpaLogger.kt
new file mode 100644
index 00000000..215f6b96
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/common/SpaLogger.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.common
+
+import android.os.Bundle
+
+// Defines the category of the log, for quick filter
+enum class LogCategory {
+ // The default category, for logs from Pages & their Models.
+ DEFAULT,
+
+ // For logs from Spa Framework, such as BrowseActivity, EntryProvider
+ FRAMEWORK,
+
+ // For logs from Spa UI components, such as Widgets, Scaffold
+ VIEW,
+}
+
+// Defines the log events in Spa.
+enum class LogEvent {
+ // Page related events.
+ PAGE_ENTER,
+ PAGE_LEAVE,
+
+ // Entry related events.
+ ENTRY_CLICK,
+ ENTRY_SWITCH,
+}
+
+internal const val LOG_DATA_DISPLAY_NAME = "name"
+internal const val LOG_DATA_SWITCH_STATUS = "switch"
+
+const val LOG_DATA_SESSION_NAME = "session"
+
+/**
+ * The interface of logger in Spa
+ */
+interface SpaLogger {
+ // log a message, usually for debug purpose.
+ fun message(tag: String, msg: String, category: LogCategory = LogCategory.DEFAULT) {}
+
+ // log a user event.
+ fun event(
+ id: String,
+ event: LogEvent,
+ category: LogCategory = LogCategory.DEFAULT,
+ extraData: Bundle = Bundle.EMPTY
+ ) {
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/AnimatedNavGraphBuilder.kt b/spa/src/com/android/settingslib/spa/framework/compose/AnimatedNavGraphBuilder.kt
new file mode 100644
index 00000000..93ad644b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/AnimatedNavGraphBuilder.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.compose
+
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.IntOffset
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.android.settingslib.spa.framework.common.NullPageProvider
+
+/**
+ * Add the [Composable] to the [NavGraphBuilder] with animation
+ *
+ * @param route route for the destination
+ * @param arguments list of arguments to associate with destination
+ * @param deepLinks list of deep links to associate with the destinations
+ * @param content composable for the destination
+ */
+internal fun NavGraphBuilder.animatedComposable(
+ route: String,
+ arguments: List = emptyList(),
+ deepLinks: List = emptyList(),
+ content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
+) = composable(
+ route = route,
+ arguments = arguments,
+ deepLinks = deepLinks,
+ enterTransition = {
+ if (initialState.destination.route != NullPageProvider.name) {
+ slideIntoContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.Start,
+ animationSpec = slideInEffect,
+ initialOffset = offsetFunc,
+ ) + fadeIn(animationSpec = fadeInEffect)
+ } else null
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.Start,
+ animationSpec = slideOutEffect,
+ targetOffset = offsetFunc,
+ ) + fadeOut(animationSpec = fadeOutEffect)
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.End,
+ animationSpec = slideInEffect,
+ initialOffset = offsetFunc,
+ ) + fadeIn(animationSpec = fadeInEffect)
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.End,
+ animationSpec = slideOutEffect,
+ targetOffset = offsetFunc,
+ ) + fadeOut(animationSpec = fadeOutEffect)
+ },
+ content = content,
+)
+
+private const val FADE_OUT_MILLIS = 75
+private const val FADE_IN_MILLIS = 300
+
+private val slideInEffect = tween(
+ durationMillis = FADE_IN_MILLIS,
+ delayMillis = FADE_OUT_MILLIS,
+ easing = LinearOutSlowInEasing,
+)
+private val slideOutEffect = tween(durationMillis = FADE_IN_MILLIS)
+private val fadeOutEffect = tween(
+ durationMillis = FADE_OUT_MILLIS,
+ easing = FastOutLinearInEasing,
+)
+private val fadeInEffect = tween(
+ durationMillis = FADE_IN_MILLIS,
+ delayMillis = FADE_OUT_MILLIS,
+ easing = LinearOutSlowInEasing,
+)
+private val offsetFunc: (offsetForFullSlide: Int) -> Int = { it.div(5) }
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/DrawablePainter.kt b/spa/src/com/android/settingslib/spa/framework/compose/DrawablePainter.kt
new file mode 100644
index 00000000..e3e12206
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/DrawablePainter.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.RememberObserver
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.asAndroidColorFilter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.withSave
+import androidx.compose.ui.unit.LayoutDirection
+import kotlin.math.roundToInt
+
+/**
+ * *************************************************************************************************
+ * This file was forked from
+ * https://github.com/google/accompanist/blob/main/drawablepainter/src/main/java/com/google/accompanist/drawablepainter/DrawablePainter.kt
+ * and will be removed once it lands in AndroidX.
+ */
+
+private val MAIN_HANDLER by lazy(LazyThreadSafetyMode.NONE) {
+ Handler(Looper.getMainLooper())
+}
+
+/**
+ * A [Painter] which draws an Android [Drawable] and supports [Animatable] drawables. Instances
+ * should be remembered to be able to start and stop [Animatable] animations.
+ *
+ * Instances are usually retrieved from [rememberDrawablePainter].
+ */
+class DrawablePainter(
+ val drawable: Drawable
+) : Painter(), RememberObserver {
+ private var drawInvalidateTick by mutableStateOf(0)
+ private var drawableIntrinsicSize by mutableStateOf(drawable.intrinsicSize)
+
+ private val callback: Drawable.Callback by lazy {
+ object : Drawable.Callback {
+ override fun invalidateDrawable(d: Drawable) {
+ // Update the tick so that we get re-drawn
+ drawInvalidateTick++
+ // Update our intrinsic size too
+ drawableIntrinsicSize = drawable.intrinsicSize
+ }
+
+ override fun scheduleDrawable(d: Drawable, what: Runnable, time: Long) {
+ MAIN_HANDLER.postAtTime(what, time)
+ }
+
+ override fun unscheduleDrawable(d: Drawable, what: Runnable) {
+ MAIN_HANDLER.removeCallbacks(what)
+ }
+ }
+ }
+
+ init {
+ if (drawable.intrinsicWidth >= 0 && drawable.intrinsicHeight >= 0) {
+ // Update the drawable's bounds to match the intrinsic size
+ drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
+ }
+ }
+
+ override fun onRemembered() {
+ drawable.callback = callback
+ drawable.setVisible(true, true)
+ if (drawable is Animatable) drawable.start()
+ }
+
+ override fun onAbandoned() = onForgotten()
+
+ override fun onForgotten() {
+ if (drawable is Animatable) drawable.stop()
+ drawable.setVisible(false, false)
+ drawable.callback = null
+ }
+
+ override fun applyAlpha(alpha: Float): Boolean {
+ drawable.alpha = (alpha * 255).roundToInt().coerceIn(0, 255)
+ return true
+ }
+
+ override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
+ drawable.colorFilter = colorFilter?.asAndroidColorFilter()
+ return true
+ }
+
+ override fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean {
+ if (Build.VERSION.SDK_INT >= 23) {
+ return drawable.setLayoutDirection(
+ when (layoutDirection) {
+ LayoutDirection.Ltr -> View.LAYOUT_DIRECTION_LTR
+ LayoutDirection.Rtl -> View.LAYOUT_DIRECTION_RTL
+ }
+ )
+ }
+ return false
+ }
+
+ override val intrinsicSize: Size get() = drawableIntrinsicSize
+
+ override fun DrawScope.onDraw() {
+ drawIntoCanvas { canvas ->
+ // Reading this ensures that we invalidate when invalidateDrawable() is called
+ drawInvalidateTick
+
+ // Update the Drawable's bounds
+ drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
+
+ canvas.withSave {
+ drawable.draw(canvas.nativeCanvas)
+ }
+ }
+ }
+}
+
+/**
+ * Remembers [Drawable] wrapped up as a [Painter]. This function attempts to un-wrap the
+ * drawable contents and use Compose primitives where possible.
+ *
+ * If the provided [drawable] is `null`, an empty no-op painter is returned.
+ *
+ * This function tries to dispatch lifecycle events to [drawable] as much as possible from
+ * within Compose.
+ *
+ * @sample com.google.accompanist.sample.drawablepainter.BasicSample
+ */
+@Composable
+fun rememberDrawablePainter(drawable: Drawable?): Painter = remember(drawable) {
+ when (drawable) {
+ null -> EmptyPainter
+ is BitmapDrawable -> BitmapPainter(drawable.bitmap.asImageBitmap())
+ is ColorDrawable -> ColorPainter(Color(drawable.color))
+ // Since the DrawablePainter will be remembered and it implements RememberObserver, it
+ // will receive the necessary events
+ else -> DrawablePainter(drawable.mutate())
+ }
+}
+
+private val Drawable.intrinsicSize: Size
+ get() = when {
+ // Only return a finite size if the drawable has an intrinsic size
+ intrinsicWidth >= 0 && intrinsicHeight >= 0 -> {
+ Size(width = intrinsicWidth.toFloat(), height = intrinsicHeight.toFloat())
+ }
+ else -> Size.Unspecified
+ }
+
+internal object EmptyPainter : Painter() {
+ override val intrinsicSize: Size get() = Size.Unspecified
+ override fun DrawScope.onDraw() {}
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt b/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt
new file mode 100644
index 00000000..b6500340
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+
+/**
+ * An action when run, hides the keyboard if it's open.
+ */
+@Composable
+fun hideKeyboardAction(): () -> Unit {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ return { keyboardController?.hide() }
+}
+
+/**
+ * Creates a [LazyListState] that is remembered across compositions.
+ *
+ * And when user scrolling the lazy list, hides the keyboard if it's open.
+ */
+@Composable
+fun rememberLazyListStateAndHideKeyboardWhenStartScroll(): LazyListState {
+ val listState = rememberLazyListState()
+ val keyboardController = LocalSoftwareKeyboardController.current
+ LaunchedEffect(listState) {
+ snapshotFlow { listState.isScrollInProgress }
+ .distinctUntilChanged()
+ .filter { it }
+ .collect { keyboardController?.hide() }
+ }
+ return listState
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/LifecycleEffect.kt b/spa/src/com/android/settingslib/spa/framework/compose/LifecycleEffect.kt
new file mode 100644
index 00000000..e91fa654
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/LifecycleEffect.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+
+@Composable
+fun LifecycleEffect(
+ onStart: () -> Unit = {},
+ onStop: () -> Unit = {},
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_START) {
+ onStart()
+ } else if (event == Lifecycle.Event.ON_STOP) {
+ onStop()
+ }
+ }
+
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/LogCompositions.kt b/spa/src/com/android/settingslib/spa/framework/compose/LogCompositions.kt
new file mode 100644
index 00000000..4eef2a8f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/LogCompositions.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
+
+const val ENABLE_LOG_COMPOSITIONS = false
+
+data class LogCompositionsRef(var count: Int)
+
+// Note the inline function below which ensures that this function is essentially
+// copied at the call site to ensure that its logging only recompositions from the
+// original call site.
+@Suppress("NOTHING_TO_INLINE")
+@Composable
+inline fun LogCompositions(tag: String, msg: String) {
+ if (ENABLE_LOG_COMPOSITIONS) {
+ val ref = remember { LogCompositionsRef(0) }
+ SideEffect { ref.count++ }
+ Log.d(tag, "Compositions $msg: ${ref.count}")
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/NavControllerWrapper.kt b/spa/src/com/android/settingslib/spa/framework/compose/NavControllerWrapper.kt
new file mode 100644
index 00000000..1aa20790
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/NavControllerWrapper.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ProvidedValue
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import androidx.navigation.NavHostController
+
+interface NavControllerWrapper {
+ fun navigate(route: String, popUpCurrent: Boolean = false)
+ fun navigateBack()
+
+ val highlightEntryId: String?
+ get() = null
+
+ val sessionSourceName: String?
+ get() = null
+}
+
+@Composable
+fun NavHostController.localNavController(): ProvidedValue {
+ val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
+ return LocalNavController provides remember {
+ NavControllerWrapperImpl(
+ navController = this,
+ onBackPressedDispatcher = onBackPressedDispatcherOwner?.onBackPressedDispatcher,
+ )
+ }
+}
+
+val LocalNavController = compositionLocalOf {
+ object : NavControllerWrapper {
+ override fun navigate(route: String, popUpCurrent: Boolean) {}
+
+ override fun navigateBack() {}
+ }
+}
+
+@Composable
+fun navigator(route: String?, popUpCurrent: Boolean = false): () -> Unit {
+ if (route == null) return {}
+ val navController = LocalNavController.current
+ return { navController.navigate(route, popUpCurrent) }
+}
+
+internal class NavControllerWrapperImpl(
+ val navController: NavHostController,
+ private val onBackPressedDispatcher: OnBackPressedDispatcher?,
+) : NavControllerWrapper {
+ var highlightId: String? = null
+ var sessionName: String? = null
+
+ override fun navigate(route: String, popUpCurrent: Boolean) {
+ navController.navigate(route) {
+ if (popUpCurrent) {
+ navController.currentDestination?.let { currentDestination ->
+ popUpTo(currentDestination.id) {
+ inclusive = true
+ }
+ }
+ }
+ }
+ }
+
+ override fun navigateBack() {
+ onBackPressedDispatcher?.onBackPressed()
+ }
+
+ override val highlightEntryId: String?
+ get() = highlightId
+
+ override val sessionSourceName: String?
+ get() = sessionName
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/OnBackEffect.kt b/spa/src/com/android/settingslib/spa/framework/compose/OnBackEffect.kt
new file mode 100644
index 00000000..3991f26e
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/OnBackEffect.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.compose
+
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.platform.LocalLifecycleOwner
+
+/**
+ * An effect for detecting presses of the system back button, and the back event will not be
+ * consumed by this effect.
+ *
+ * Calling this in your composable adds the given lambda to the [OnBackPressedDispatcher] of the
+ * [LocalOnBackPressedDispatcherOwner].
+ *
+ * @param onBack the action invoked by pressing the system back
+ */
+@Composable
+fun OnBackEffect(onBack: () -> Unit) {
+ val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
+ "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
+ }.onBackPressedDispatcher
+
+ // Safely update the current `onBack` lambda when a new one is provided
+ val currentOnBack by rememberUpdatedState(onBack)
+ // Remember in Composition a back callback that calls the `onBack` lambda
+ val backCallback = remember {
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ remove()
+ currentOnBack()
+ backDispatcher.onBackPressed()
+ }
+ }
+ }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, backDispatcher) {
+ // Add callback to the backDispatcher
+ backDispatcher.addCallback(lifecycleOwner, backCallback)
+ // When the effect leaves the Composition, remove the callback
+ onDispose {
+ backCallback.remove()
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/OverridableFlow.kt b/spa/src/com/android/settingslib/spa/framework/compose/OverridableFlow.kt
new file mode 100644
index 00000000..1b33dd6d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/OverridableFlow.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 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.settingslib.spa.framework.compose
+
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.receiveAsFlow
+
+/**
+ * A flow which result is overridable.
+ */
+class OverridableFlow(flow: Flow) {
+ private val overrideChannel = Channel()
+
+ val flow = merge(overrideChannel.receiveAsFlow(), flow)
+
+ fun override(value: T) {
+ overrideChannel.trySend(value)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/PaddingValuesExt.kt b/spa/src/com/android/settingslib/spa/framework/compose/PaddingValuesExt.kt
new file mode 100644
index 00000000..18335ff6
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/PaddingValuesExt.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+
+internal fun PaddingValues.horizontalValues(): PaddingValues = HorizontalPaddingValues(this)
+
+internal fun PaddingValues.verticalValues(): PaddingValues = VerticalPaddingValues(this)
+
+private class HorizontalPaddingValues(private val paddingValues: PaddingValues) : PaddingValues {
+ override fun calculateLeftPadding(layoutDirection: LayoutDirection) =
+ paddingValues.calculateLeftPadding(layoutDirection)
+
+ override fun calculateTopPadding(): Dp = 0.dp
+
+ override fun calculateRightPadding(layoutDirection: LayoutDirection) =
+ paddingValues.calculateRightPadding(layoutDirection)
+
+ override fun calculateBottomPadding() = 0.dp
+}
+
+private class VerticalPaddingValues(private val paddingValues: PaddingValues) : PaddingValues {
+ override fun calculateLeftPadding(layoutDirection: LayoutDirection) = 0.dp
+
+ override fun calculateTopPadding(): Dp = paddingValues.calculateTopPadding()
+
+ override fun calculateRightPadding(layoutDirection: LayoutDirection) = 0.dp
+
+ override fun calculateBottomPadding() = paddingValues.calculateBottomPadding()
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/RuntimeUtils.kt b/spa/src/com/android/settingslib/spa/framework/compose/RuntimeUtils.kt
new file mode 100644
index 00000000..b97fb9ca
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/RuntimeUtils.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.compose
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun rememberContext(constructor: (Context) -> T): T {
+ val context = LocalContext.current
+ return remember(context) { constructor(context) }
+}
+
+/**
+ * Return a new [State] initialized with the passed in [value].
+ */
+fun stateOf(value: T) = object : State {
+ override val value = value
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/compose/TimeMeasurer.kt b/spa/src/com/android/settingslib/spa/framework/compose/TimeMeasurer.kt
new file mode 100644
index 00000000..b23f4e08
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/compose/TimeMeasurer.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalTime::class)
+
+package com.android.settingslib.spa.framework.compose
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import kotlin.time.ExperimentalTime
+import kotlin.time.TimeSource
+
+const val ENABLE_MEASURE_TIME = false
+
+interface TimeMeasurer {
+ fun log(msg: String) {}
+ fun logFirst(msg: String) {}
+
+ companion object {
+ private object EmptyTimeMeasurer : TimeMeasurer
+
+ @Composable
+ fun rememberTimeMeasurer(tag: String): TimeMeasurer = remember {
+ if (ENABLE_MEASURE_TIME) TimeMeasurerImpl(tag) else EmptyTimeMeasurer
+ }
+ }
+}
+
+private class TimeMeasurerImpl(private val tag: String) : TimeMeasurer {
+ private val mark = TimeSource.Monotonic.markNow()
+ private val msgLogged = mutableSetOf()
+
+ override fun log(msg: String) {
+ Log.d(tag, "Timer $msg: ${mark.elapsedNow()}")
+ }
+
+ override fun logFirst(msg: String) {
+ if (msgLogged.add(msg)) {
+ Log.d(tag, "Timer $msg: ${mark.elapsedNow()}")
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/MaterialColors.kt b/spa/src/com/android/settingslib/spa/framework/theme/MaterialColors.kt
new file mode 100644
index 00000000..52c48932
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/MaterialColors.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import android.os.Build
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+internal fun materialColorScheme(isDarkTheme: Boolean): ColorScheme {
+ val context = LocalContext.current
+ return remember(isDarkTheme) {
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (isDarkTheme) dynamicDarkColorScheme(context)
+ else dynamicLightColorScheme(context)
+ }
+ isDarkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+ }
+}
+
+val ColorScheme.divider: Color
+ get() = onSurface.copy(SettingsOpacity.Divider)
+
+val ColorScheme.surfaceTone: Color
+ get() = primary.copy(SettingsOpacity.SurfaceTone)
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt
new file mode 100644
index 00000000..d72ec264
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsColors.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+data class SettingsColorScheme(
+ val background: Color = Color.Unspecified,
+ val categoryTitle: Color = Color.Unspecified,
+ val surface: Color = Color.Unspecified,
+ val surfaceHeader: Color = Color.Unspecified,
+ val secondaryText: Color = Color.Unspecified,
+ val primaryContainer: Color = Color.Unspecified,
+ val onPrimaryContainer: Color = Color.Unspecified,
+ val spinnerHeaderContainer: Color = Color.Unspecified,
+ val onSpinnerHeaderContainer: Color = Color.Unspecified,
+ val spinnerItemContainer: Color = Color.Unspecified,
+ val onSpinnerItemContainer: Color = Color.Unspecified,
+)
+
+internal val LocalColorScheme = staticCompositionLocalOf { SettingsColorScheme() }
+
+@Composable
+internal fun settingsColorScheme(isDarkTheme: Boolean): SettingsColorScheme {
+ val context = LocalContext.current
+ return remember(isDarkTheme) {
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (isDarkTheme) dynamicDarkColorScheme(context)
+ else dynamicLightColorScheme(context)
+ }
+ isDarkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+ }
+}
+
+/**
+ * Creates a light dynamic color scheme.
+ *
+ * Use this function to create a color scheme based off the system wallpaper. If the developer
+ * changes the wallpaper this color scheme will change accordingly. This dynamic scheme is a
+ * light theme variant.
+ *
+ * @param context The context required to get system resource data.
+ */
+@VisibleForTesting
+internal fun dynamicLightColorScheme(context: Context): SettingsColorScheme {
+ val tonalPalette = dynamicTonalPalette(context)
+ return SettingsColorScheme(
+ background = tonalPalette.neutral95,
+ categoryTitle = tonalPalette.primary40,
+ surface = tonalPalette.neutral99,
+ surfaceHeader = tonalPalette.neutral90,
+ secondaryText = tonalPalette.neutralVariant30,
+ primaryContainer = tonalPalette.primary90,
+ onPrimaryContainer = tonalPalette.neutral10,
+ spinnerHeaderContainer = tonalPalette.primary90,
+ onSpinnerHeaderContainer = tonalPalette.neutral10,
+ spinnerItemContainer = tonalPalette.secondary90,
+ onSpinnerItemContainer = tonalPalette.neutralVariant30,
+ )
+}
+
+/**
+ * Creates a dark dynamic color scheme.
+ *
+ * Use this function to create a color scheme based off the system wallpaper. If the developer
+ * changes the wallpaper this color scheme will change accordingly. This dynamic scheme is a dark
+ * theme variant.
+ *
+ * @param context The context required to get system resource data.
+ */
+@VisibleForTesting
+internal fun dynamicDarkColorScheme(context: Context): SettingsColorScheme {
+ val tonalPalette = dynamicTonalPalette(context)
+ return SettingsColorScheme(
+ background = tonalPalette.neutral10,
+ categoryTitle = tonalPalette.primary90,
+ surface = tonalPalette.neutral20,
+ surfaceHeader = tonalPalette.neutral30,
+ secondaryText = tonalPalette.neutralVariant80,
+ primaryContainer = tonalPalette.secondary90,
+ onPrimaryContainer = tonalPalette.neutral10,
+ spinnerHeaderContainer = tonalPalette.primary90,
+ onSpinnerHeaderContainer = tonalPalette.neutral10,
+ spinnerItemContainer = tonalPalette.secondary90,
+ onSpinnerItemContainer = tonalPalette.neutralVariant30,
+ )
+}
+
+@VisibleForTesting
+internal fun darkColorScheme(): SettingsColorScheme {
+ val tonalPalette = tonalPalette()
+ return SettingsColorScheme(
+ background = tonalPalette.neutral10,
+ categoryTitle = tonalPalette.primary90,
+ surface = tonalPalette.neutral20,
+ surfaceHeader = tonalPalette.neutral30,
+ secondaryText = tonalPalette.neutralVariant80,
+ primaryContainer = tonalPalette.secondary90,
+ onPrimaryContainer = tonalPalette.neutral10,
+ spinnerHeaderContainer = tonalPalette.primary90,
+ onSpinnerHeaderContainer = tonalPalette.neutral10,
+ spinnerItemContainer = tonalPalette.secondary90,
+ onSpinnerItemContainer = tonalPalette.neutralVariant30,
+ )
+}
+
+@VisibleForTesting
+internal fun lightColorScheme(): SettingsColorScheme {
+ val tonalPalette = tonalPalette()
+ return SettingsColorScheme(
+ background = tonalPalette.neutral95,
+ categoryTitle = tonalPalette.primary40,
+ surface = tonalPalette.neutral99,
+ surfaceHeader = tonalPalette.neutral90,
+ secondaryText = tonalPalette.neutralVariant30,
+ primaryContainer = tonalPalette.primary90,
+ onPrimaryContainer = tonalPalette.neutral10,
+ spinnerHeaderContainer = tonalPalette.primary90,
+ onSpinnerHeaderContainer = tonalPalette.neutral10,
+ spinnerItemContainer = tonalPalette.secondary90,
+ onSpinnerItemContainer = tonalPalette.neutralVariant30,
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsDimension.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsDimension.kt
new file mode 100644
index 00000000..b7f2c1e5
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsDimension.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.theme
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.dp
+
+object SettingsDimension {
+ val paddingTiny = 2.dp
+ val paddingSmall = 4.dp
+
+ val itemIconSize = 24.dp
+ val itemIconContainerSize = 72.dp
+ val itemPaddingStart = 24.dp
+ val itemPaddingEnd = 16.dp
+ val itemPaddingVertical = 16.dp
+ val itemPadding = PaddingValues(
+ start = itemPaddingStart,
+ top = itemPaddingVertical,
+ end = itemPaddingEnd,
+ bottom = itemPaddingVertical,
+ )
+ val textFieldPadding = PaddingValues(
+ start = itemPaddingStart,
+ end = itemPaddingEnd,
+ )
+ val menuFieldPadding = PaddingValues(
+ start = itemPaddingStart,
+ end = itemPaddingEnd,
+ bottom = itemPaddingVertical,
+ )
+ val itemPaddingAround = 8.dp
+ val itemDividerHeight = 32.dp
+
+ val iconLarge = 48.dp
+
+ /** The size when app icon is displayed in list. */
+ val appIconItemSize = 32.dp
+
+ /** The size when app icon is displayed in App info page. */
+ val appIconInfoSize = iconLarge
+
+ /** The vertical padding for buttons. */
+ val buttonPaddingVertical = 12.dp
+
+ /** The [PaddingValues] for buttons. */
+ val buttonPadding = PaddingValues(horizontal = itemPaddingEnd, vertical = buttonPaddingVertical)
+
+ /** The horizontal padding for dialog items. */
+ val dialogItemPaddingHorizontal = itemPaddingStart
+
+ /** The [PaddingValues] for dialog items. */
+ val dialogItemPadding =
+ PaddingValues(horizontal = dialogItemPaddingHorizontal, vertical = buttonPaddingVertical)
+
+ /** The sizes info of illustration widget. */
+ val illustrationMaxWidth = 412.dp
+ val illustrationMaxHeight = 300.dp
+ val illustrationPadding = 16.dp
+ val illustrationCornerRadius = 28.dp
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsFontFamily.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsFontFamily.kt
new file mode 100644
index 00000000..94792289
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsFontFamily.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalTextApi::class)
+
+package com.android.settingslib.spa.framework.theme
+
+import android.annotation.SuppressLint
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.font.DeviceFontFamilyName
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import com.android.settingslib.spa.framework.compose.rememberContext
+
+internal data class SettingsFontFamily(
+ val brand: FontFamily,
+ val plain: FontFamily,
+)
+
+private fun Context.getSettingsFontFamily(): SettingsFontFamily {
+ return SettingsFontFamily(
+ brand = getFontFamily(
+ configFontFamilyNormal = "config_headlineFontFamily",
+ configFontFamilyMedium = "config_headlineFontFamilyMedium",
+ ),
+ plain = getFontFamily(
+ configFontFamilyNormal = "config_bodyFontFamily",
+ configFontFamilyMedium = "config_bodyFontFamilyMedium",
+ ),
+ )
+}
+
+private fun Context.getFontFamily(
+ configFontFamilyNormal: String,
+ configFontFamilyMedium: String,
+): FontFamily {
+ val fontFamilyNormal = getAndroidConfig(configFontFamilyNormal)
+ val fontFamilyMedium = getAndroidConfig(configFontFamilyMedium)
+ if (fontFamilyNormal.isEmpty() || fontFamilyMedium.isEmpty()) return FontFamily.Default
+ return FontFamily(
+ Font(DeviceFontFamilyName(fontFamilyNormal), FontWeight.Normal),
+ Font(DeviceFontFamilyName(fontFamilyMedium), FontWeight.Medium),
+ )
+}
+
+private fun Context.getAndroidConfig(configName: String): String {
+ @SuppressLint("DiscouragedApi")
+ val configId = resources.getIdentifier(configName, "string", "android")
+ return resources.getString(configId)
+}
+
+@Composable
+internal fun rememberSettingsFontFamily(): SettingsFontFamily {
+ return rememberContext(Context::getSettingsFontFamily)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsOpacity.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsOpacity.kt
new file mode 100644
index 00000000..a9cd0e9c
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsOpacity.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+
+object SettingsOpacity {
+ const val Full = 1f
+ const val Disabled = 0.38f
+ const val Divider = 0.2f
+ const val SurfaceTone = 0.14f
+ const val Hint = 0.9f
+
+ fun Modifier.alphaForEnabled(enabled: Boolean) = alpha(if (enabled) Full else Disabled)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsShape.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsShape.kt
new file mode 100644
index 00000000..f7c5414a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsShape.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+
+object SettingsShape {
+ val CornerExtraSmall = RoundedCornerShape(4.dp)
+
+ val CornerMedium = RoundedCornerShape(12.dp)
+
+ val CornerExtraLarge = RoundedCornerShape(28.dp)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsTheme.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTheme.kt
new file mode 100644
index 00000000..c395558b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTheme.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ReadOnlyComposable
+
+/**
+ * The Material 3 Theme for Settings.
+ */
+@Composable
+fun SettingsTheme(content: @Composable () -> Unit) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val settingsColorScheme = settingsColorScheme(isDarkTheme)
+ val colorScheme = materialColorScheme(isDarkTheme).copy(
+ background = settingsColorScheme.background,
+ )
+
+ MaterialTheme(colorScheme = colorScheme, typography = rememberSettingsTypography()) {
+ CompositionLocalProvider(
+ LocalColorScheme provides settingsColorScheme(isDarkTheme),
+ LocalContentColor provides MaterialTheme.colorScheme.onSurface,
+ ) {
+ content()
+ }
+ }
+}
+
+object SettingsTheme {
+ val colorScheme: SettingsColorScheme
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalColorScheme.current
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsTonalPalette.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTonalPalette.kt
new file mode 100644
index 00000000..c205aae8
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTonalPalette.kt
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import android.R
+import android.content.Context
+import androidx.annotation.ColorRes
+import androidx.annotation.DoNotInline
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Tonal Palette structure in Material.
+ *
+ * A tonal palette is comprised of 5 tonal ranges. Each tonal range includes the 13 stops, or
+ * tonal swatches.
+ *
+ * Tonal range names are:
+ * - Neutral (N)
+ * - Neutral variant (NV)
+ * - Primary (P)
+ * - Secondary (S)
+ * - Tertiary (T)
+ */
+internal class SettingsTonalPalette(
+ // The neutral tonal range from the generated dynamic color palette.
+ // Ordered from the lightest shade [neutral100] to the darkest shade [neutral0].
+ val neutral100: Color,
+ val neutral99: Color,
+ val neutral95: Color,
+ val neutral90: Color,
+ val neutral80: Color,
+ val neutral70: Color,
+ val neutral60: Color,
+ val neutral50: Color,
+ val neutral40: Color,
+ val neutral30: Color,
+ val neutral20: Color,
+ val neutral10: Color,
+ val neutral0: Color,
+
+ // The neutral variant tonal range, sometimes called "neutral 2", from the
+ // generated dynamic color palette.
+ // Ordered from the lightest shade [neutralVariant100] to the darkest shade [neutralVariant0].
+ val neutralVariant100: Color,
+ val neutralVariant99: Color,
+ val neutralVariant95: Color,
+ val neutralVariant90: Color,
+ val neutralVariant80: Color,
+ val neutralVariant70: Color,
+ val neutralVariant60: Color,
+ val neutralVariant50: Color,
+ val neutralVariant40: Color,
+ val neutralVariant30: Color,
+ val neutralVariant20: Color,
+ val neutralVariant10: Color,
+ val neutralVariant0: Color,
+
+ // The primary tonal range from the generated dynamic color palette.
+ // Ordered from the lightest shade [primary100] to the darkest shade [primary0].
+ val primary100: Color,
+ val primary99: Color,
+ val primary95: Color,
+ val primary90: Color,
+ val primary80: Color,
+ val primary70: Color,
+ val primary60: Color,
+ val primary50: Color,
+ val primary40: Color,
+ val primary30: Color,
+ val primary20: Color,
+ val primary10: Color,
+ val primary0: Color,
+
+ // The secondary tonal range from the generated dynamic color palette.
+ // Ordered from the lightest shade [secondary100] to the darkest shade [secondary0].
+ val secondary100: Color,
+ val secondary99: Color,
+ val secondary95: Color,
+ val secondary90: Color,
+ val secondary80: Color,
+ val secondary70: Color,
+ val secondary60: Color,
+ val secondary50: Color,
+ val secondary40: Color,
+ val secondary30: Color,
+ val secondary20: Color,
+ val secondary10: Color,
+ val secondary0: Color,
+
+ // The tertiary tonal range from the generated dynamic color palette.
+ // Ordered from the lightest shade [tertiary100] to the darkest shade [tertiary0].
+ val tertiary100: Color,
+ val tertiary99: Color,
+ val tertiary95: Color,
+ val tertiary90: Color,
+ val tertiary80: Color,
+ val tertiary70: Color,
+ val tertiary60: Color,
+ val tertiary50: Color,
+ val tertiary40: Color,
+ val tertiary30: Color,
+ val tertiary20: Color,
+ val tertiary10: Color,
+ val tertiary0: Color,
+)
+
+/** Static colors in Material. */
+internal fun tonalPalette() = SettingsTonalPalette(
+ // The neutral static tonal range.
+ neutral100 = Color(red = 255, green = 255, blue = 255),
+ neutral99 = Color(red = 255, green = 251, blue = 254),
+ neutral95 = Color(red = 244, green = 239, blue = 244),
+ neutral90 = Color(red = 230, green = 225, blue = 229),
+ neutral80 = Color(red = 201, green = 197, blue = 202),
+ neutral70 = Color(red = 174, green = 170, blue = 174),
+ neutral60 = Color(red = 147, green = 144, blue = 148),
+ neutral50 = Color(red = 120, green = 117, blue = 121),
+ neutral40 = Color(red = 96, green = 93, blue = 98),
+ neutral30 = Color(red = 72, green = 70, blue = 73),
+ neutral20 = Color(red = 49, green = 48, blue = 51),
+ neutral10 = Color(red = 28, green = 27, blue = 31),
+ neutral0 = Color(red = 0, green = 0, blue = 0),
+
+ // The neutral variant static tonal range, sometimes called "neutral 2".
+ neutralVariant100 = Color(red = 255, green = 255, blue = 255),
+ neutralVariant99 = Color(red = 255, green = 251, blue = 254),
+ neutralVariant95 = Color(red = 245, green = 238, blue = 250),
+ neutralVariant90 = Color(red = 231, green = 224, blue = 236),
+ neutralVariant80 = Color(red = 202, green = 196, blue = 208),
+ neutralVariant70 = Color(red = 174, green = 169, blue = 180),
+ neutralVariant60 = Color(red = 147, green = 143, blue = 153),
+ neutralVariant50 = Color(red = 121, green = 116, blue = 126),
+ neutralVariant40 = Color(red = 96, green = 93, blue = 102),
+ neutralVariant30 = Color(red = 73, green = 69, blue = 79),
+ neutralVariant20 = Color(red = 50, green = 47, blue = 55),
+ neutralVariant10 = Color(red = 29, green = 26, blue = 34),
+ neutralVariant0 = Color(red = 0, green = 0, blue = 0),
+
+ // The primary static tonal range.
+ primary100 = Color(red = 255, green = 255, blue = 255),
+ primary99 = Color(red = 255, green = 251, blue = 254),
+ primary95 = Color(red = 246, green = 237, blue = 255),
+ primary90 = Color(red = 234, green = 221, blue = 255),
+ primary80 = Color(red = 208, green = 188, blue = 255),
+ primary70 = Color(red = 182, green = 157, blue = 248),
+ primary60 = Color(red = 154, green = 130, blue = 219),
+ primary50 = Color(red = 127, green = 103, blue = 190),
+ primary40 = Color(red = 103, green = 80, blue = 164),
+ primary30 = Color(red = 79, green = 55, blue = 139),
+ primary20 = Color(red = 56, green = 30, blue = 114),
+ primary10 = Color(red = 33, green = 0, blue = 93),
+ primary0 = Color(red = 33, green = 0, blue = 93),
+
+ // The secondary static tonal range.
+ secondary100 = Color(red = 255, green = 255, blue = 255),
+ secondary99 = Color(red = 255, green = 251, blue = 254),
+ secondary95 = Color(red = 246, green = 237, blue = 255),
+ secondary90 = Color(red = 232, green = 222, blue = 248),
+ secondary80 = Color(red = 204, green = 194, blue = 220),
+ secondary70 = Color(red = 176, green = 167, blue = 192),
+ secondary60 = Color(red = 149, green = 141, blue = 165),
+ secondary50 = Color(red = 122, green = 114, blue = 137),
+ secondary40 = Color(red = 98, green = 91, blue = 113),
+ secondary30 = Color(red = 74, green = 68, blue = 88),
+ secondary20 = Color(red = 51, green = 45, blue = 65),
+ secondary10 = Color(red = 29, green = 25, blue = 43),
+ secondary0 = Color(red = 0, green = 0, blue = 0),
+
+ // The tertiary static tonal range.
+ tertiary100 = Color(red = 255, green = 255, blue = 255),
+ tertiary99 = Color(red = 255, green = 251, blue = 250),
+ tertiary95 = Color(red = 255, green = 236, blue = 241),
+ tertiary90 = Color(red = 255, green = 216, blue = 228),
+ tertiary80 = Color(red = 239, green = 184, blue = 200),
+ tertiary70 = Color(red = 210, green = 157, blue = 172),
+ tertiary60 = Color(red = 181, green = 131, blue = 146),
+ tertiary50 = Color(red = 152, green = 105, blue = 119),
+ tertiary40 = Color(red = 125, green = 82, blue = 96),
+ tertiary30 = Color(red = 99, green = 59, blue = 72),
+ tertiary20 = Color(red = 73, green = 37, blue = 50),
+ tertiary10 = Color(red = 49, green = 17, blue = 29),
+ tertiary0 = Color(red = 0, green = 0, blue = 0),
+)
+
+/** Dynamic colors in Material. */
+internal fun dynamicTonalPalette(context: Context) = SettingsTonalPalette(
+ // The neutral tonal range from the generated dynamic color palette.
+ neutral100 = ColorResourceHelper.getColor(context, R.color.system_neutral1_0),
+ neutral99 = ColorResourceHelper.getColor(context, R.color.system_neutral1_10),
+ neutral95 = ColorResourceHelper.getColor(context, R.color.system_neutral1_50),
+ neutral90 = ColorResourceHelper.getColor(context, R.color.system_neutral1_100),
+ neutral80 = ColorResourceHelper.getColor(context, R.color.system_neutral1_200),
+ neutral70 = ColorResourceHelper.getColor(context, R.color.system_neutral1_300),
+ neutral60 = ColorResourceHelper.getColor(context, R.color.system_neutral1_400),
+ neutral50 = ColorResourceHelper.getColor(context, R.color.system_neutral1_500),
+ neutral40 = ColorResourceHelper.getColor(context, R.color.system_neutral1_600),
+ neutral30 = ColorResourceHelper.getColor(context, R.color.system_neutral1_700),
+ neutral20 = ColorResourceHelper.getColor(context, R.color.system_neutral1_800),
+ neutral10 = ColorResourceHelper.getColor(context, R.color.system_neutral1_900),
+ neutral0 = ColorResourceHelper.getColor(context, R.color.system_neutral1_1000),
+
+ // The neutral variant tonal range, sometimes called "neutral 2", from the
+ // generated dynamic color palette.
+ neutralVariant100 = ColorResourceHelper.getColor(context, R.color.system_neutral2_0),
+ neutralVariant99 = ColorResourceHelper.getColor(context, R.color.system_neutral2_10),
+ neutralVariant95 = ColorResourceHelper.getColor(context, R.color.system_neutral2_50),
+ neutralVariant90 = ColorResourceHelper.getColor(context, R.color.system_neutral2_100),
+ neutralVariant80 = ColorResourceHelper.getColor(context, R.color.system_neutral2_200),
+ neutralVariant70 = ColorResourceHelper.getColor(context, R.color.system_neutral2_300),
+ neutralVariant60 = ColorResourceHelper.getColor(context, R.color.system_neutral2_400),
+ neutralVariant50 = ColorResourceHelper.getColor(context, R.color.system_neutral2_500),
+ neutralVariant40 = ColorResourceHelper.getColor(context, R.color.system_neutral2_600),
+ neutralVariant30 = ColorResourceHelper.getColor(context, R.color.system_neutral2_700),
+ neutralVariant20 = ColorResourceHelper.getColor(context, R.color.system_neutral2_800),
+ neutralVariant10 = ColorResourceHelper.getColor(context, R.color.system_neutral2_900),
+ neutralVariant0 = ColorResourceHelper.getColor(context, R.color.system_neutral2_1000),
+
+ // The primary tonal range from the generated dynamic color palette.
+ primary100 = ColorResourceHelper.getColor(context, R.color.system_accent1_0),
+ primary99 = ColorResourceHelper.getColor(context, R.color.system_accent1_10),
+ primary95 = ColorResourceHelper.getColor(context, R.color.system_accent1_50),
+ primary90 = ColorResourceHelper.getColor(context, R.color.system_accent1_100),
+ primary80 = ColorResourceHelper.getColor(context, R.color.system_accent1_200),
+ primary70 = ColorResourceHelper.getColor(context, R.color.system_accent1_300),
+ primary60 = ColorResourceHelper.getColor(context, R.color.system_accent1_400),
+ primary50 = ColorResourceHelper.getColor(context, R.color.system_accent1_500),
+ primary40 = ColorResourceHelper.getColor(context, R.color.system_accent1_600),
+ primary30 = ColorResourceHelper.getColor(context, R.color.system_accent1_700),
+ primary20 = ColorResourceHelper.getColor(context, R.color.system_accent1_800),
+ primary10 = ColorResourceHelper.getColor(context, R.color.system_accent1_900),
+ primary0 = ColorResourceHelper.getColor(context, R.color.system_accent1_1000),
+
+ // The secondary tonal range from the generated dynamic color palette.
+ secondary100 = ColorResourceHelper.getColor(context, R.color.system_accent2_0),
+ secondary99 = ColorResourceHelper.getColor(context, R.color.system_accent2_10),
+ secondary95 = ColorResourceHelper.getColor(context, R.color.system_accent2_50),
+ secondary90 = ColorResourceHelper.getColor(context, R.color.system_accent2_100),
+ secondary80 = ColorResourceHelper.getColor(context, R.color.system_accent2_200),
+ secondary70 = ColorResourceHelper.getColor(context, R.color.system_accent2_300),
+ secondary60 = ColorResourceHelper.getColor(context, R.color.system_accent2_400),
+ secondary50 = ColorResourceHelper.getColor(context, R.color.system_accent2_500),
+ secondary40 = ColorResourceHelper.getColor(context, R.color.system_accent2_600),
+ secondary30 = ColorResourceHelper.getColor(context, R.color.system_accent2_700),
+ secondary20 = ColorResourceHelper.getColor(context, R.color.system_accent2_800),
+ secondary10 = ColorResourceHelper.getColor(context, R.color.system_accent2_900),
+ secondary0 = ColorResourceHelper.getColor(context, R.color.system_accent2_1000),
+
+ // The tertiary tonal range from the generated dynamic color palette.
+ tertiary100 = ColorResourceHelper.getColor(context, R.color.system_accent3_0),
+ tertiary99 = ColorResourceHelper.getColor(context, R.color.system_accent3_10),
+ tertiary95 = ColorResourceHelper.getColor(context, R.color.system_accent3_50),
+ tertiary90 = ColorResourceHelper.getColor(context, R.color.system_accent3_100),
+ tertiary80 = ColorResourceHelper.getColor(context, R.color.system_accent3_200),
+ tertiary70 = ColorResourceHelper.getColor(context, R.color.system_accent3_300),
+ tertiary60 = ColorResourceHelper.getColor(context, R.color.system_accent3_400),
+ tertiary50 = ColorResourceHelper.getColor(context, R.color.system_accent3_500),
+ tertiary40 = ColorResourceHelper.getColor(context, R.color.system_accent3_600),
+ tertiary30 = ColorResourceHelper.getColor(context, R.color.system_accent3_700),
+ tertiary20 = ColorResourceHelper.getColor(context, R.color.system_accent3_800),
+ tertiary10 = ColorResourceHelper.getColor(context, R.color.system_accent3_900),
+ tertiary0 = ColorResourceHelper.getColor(context, R.color.system_accent3_1000),
+)
+
+private object ColorResourceHelper {
+ @DoNotInline
+ fun getColor(context: Context, @ColorRes id: Int): Color {
+ return Color(context.resources.getColor(id, context.theme))
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/theme/SettingsTypography.kt b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTypography.kt
new file mode 100644
index 00000000..460bf999
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/theme/SettingsTypography.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.Hyphens
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+
+private class SettingsTypography(settingsFontFamily: SettingsFontFamily) {
+ private val brand = settingsFontFamily.brand
+ private val plain = settingsFontFamily.plain
+
+ val typography = Typography(
+ displayLarge = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.2).sp,
+ hyphens = Hyphens.Auto,
+ ),
+ displayMedium = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.0.sp,
+ hyphens = Hyphens.Auto,
+ ),
+ displaySmall = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.0.sp,
+ hyphens = Hyphens.Auto,
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.0.sp,
+ hyphens = Hyphens.Auto,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.0.sp,
+ hyphens = Hyphens.Auto,
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.0.sp,
+ hyphens = Hyphens.Auto,
+ ),
+ titleLarge = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.02.em,
+ hyphens = Hyphens.Auto,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 20.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.02.em,
+ hyphens = Hyphens.Auto,
+ ),
+ titleSmall = TextStyle(
+ fontFamily = brand,
+ fontWeight = FontWeight.Normal,
+ fontSize = 18.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.02.em,
+ hyphens = Hyphens.Auto,
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ bodySmall = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ labelLarge = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ labelMedium = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ labelSmall = TextStyle(
+ fontFamily = plain,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.01.em,
+ hyphens = Hyphens.Auto,
+ ),
+ )
+}
+
+@Composable
+internal fun rememberSettingsTypography(): Typography {
+ val settingsFontFamily = rememberSettingsFontFamily()
+ return remember { SettingsTypography(settingsFontFamily).typography }
+}
+
+/** Creates a new [TextStyle] which font weight set to medium. */
+internal fun TextStyle.toMediumWeight() =
+ copy(fontWeight = FontWeight.Medium, letterSpacing = 0.01.em)
diff --git a/spa/src/com/android/settingslib/spa/framework/util/AnnotatedStringResource.kt b/spa/src/com/android/settingslib/spa/framework/util/AnnotatedStringResource.kt
new file mode 100644
index 00000000..88ba4b07
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/AnnotatedStringResource.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.util
+
+import android.graphics.Typeface
+import android.text.Spanned
+import android.text.style.StyleSpan
+import android.text.style.URLSpan
+import androidx.annotation.StringRes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+
+const val URL_SPAN_TAG = "URL_SPAN_TAG"
+
+@Composable
+fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
+ val resources = LocalContext.current.resources
+ val urlSpanColor = MaterialTheme.colorScheme.primary
+ return remember(id) {
+ val text = resources.getText(id)
+ spannableStringToAnnotatedString(text, urlSpanColor)
+ }
+}
+
+private fun spannableStringToAnnotatedString(text: CharSequence, urlSpanColor: Color) =
+ if (text is Spanned) {
+ buildAnnotatedString {
+ append((text.toString()))
+ for (span in text.getSpans(0, text.length, Any::class.java)) {
+ val start = text.getSpanStart(span)
+ val end = text.getSpanEnd(span)
+ when (span) {
+ is StyleSpan -> addStyleSpan(span, start, end)
+ is URLSpan -> addUrlSpan(span, urlSpanColor, start, end)
+ else -> addStyle(SpanStyle(), start, end)
+ }
+ }
+ }
+ } else {
+ AnnotatedString(text.toString())
+ }
+
+private fun AnnotatedString.Builder.addStyleSpan(styleSpan: StyleSpan, start: Int, end: Int) {
+ when (styleSpan.style) {
+ Typeface.NORMAL -> addStyle(
+ SpanStyle(fontWeight = FontWeight.Normal, fontStyle = FontStyle.Normal),
+ start,
+ end,
+ )
+
+ Typeface.BOLD -> addStyle(
+ SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Normal),
+ start,
+ end,
+ )
+
+ Typeface.ITALIC -> addStyle(
+ SpanStyle(fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic),
+ start,
+ end,
+ )
+
+ Typeface.BOLD_ITALIC -> addStyle(
+ SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
+ start,
+ end,
+ )
+ }
+}
+
+private fun AnnotatedString.Builder.addUrlSpan(
+ urlSpan: URLSpan,
+ urlSpanColor: Color,
+ start: Int,
+ end: Int,
+) {
+ addStyle(
+ SpanStyle(color = urlSpanColor, textDecoration = TextDecoration.Underline),
+ start,
+ end,
+ )
+ if (!urlSpan.url.isNullOrEmpty()) {
+ addStringAnnotation(URL_SPAN_TAG, urlSpan.url, start, end)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/Collections.kt b/spa/src/com/android/settingslib/spa/framework/util/Collections.kt
new file mode 100644
index 00000000..9478587f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/Collections.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Performs the given [action] on each element asynchronously.
+ */
+suspend inline fun Iterable.asyncForEach(crossinline action: (T) -> Unit) {
+ coroutineScope {
+ forEach {
+ launch { action(it) }
+ }
+ }
+}
+
+/**
+ * Returns a list containing the results of asynchronously applying the given [transform] function
+ * to each element in the original collection.
+ */
+suspend inline fun Iterable.asyncMap(crossinline transform: (T) -> R): List =
+ coroutineScope {
+ map { item ->
+ async { transform(item) }
+ }.awaitAll()
+ }
+
+/**
+ * Returns a list containing only elements matching the given [predicate].
+ *
+ * The filter operation is done asynchronously.
+ */
+suspend inline fun Iterable.asyncFilter(crossinline predicate: (T) -> Boolean): List =
+ asyncMap { item -> item to predicate(item) }
+ .filter { it.second }
+ .map { it.first }
diff --git a/spa/src/com/android/settingslib/spa/framework/util/EntryHighlight.kt b/spa/src/com/android/settingslib/spa/framework/util/EntryHighlight.kt
new file mode 100644
index 00000000..330755c0
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/EntryHighlight.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.repeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.android.settingslib.spa.framework.common.LocalEntryDataProvider
+
+@Composable
+internal fun EntryHighlight(content: @Composable () -> Unit) {
+ val entryData = LocalEntryDataProvider.current
+ val entryIsHighlighted = rememberSaveable { entryData.isHighlighted }
+ var localHighlighted by rememberSaveable { mutableStateOf(false) }
+ SideEffect {
+ localHighlighted = entryIsHighlighted
+ }
+
+ val backgroundColor by animateColorAsState(
+ targetValue = when {
+ localHighlighted -> MaterialTheme.colorScheme.surfaceVariant
+ else -> Color.Transparent
+ },
+ animationSpec = repeatable(
+ iterations = 3,
+ animation = tween(durationMillis = 500),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "BackgroundColorAnimation",
+ )
+ Box(modifier = Modifier.background(color = backgroundColor)) {
+ content()
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/EntryLogger.kt b/spa/src/com/android/settingslib/spa/framework/util/EntryLogger.kt
new file mode 100644
index 00000000..8ff43686
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/EntryLogger.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.core.os.bundleOf
+import com.android.settingslib.spa.framework.common.LOG_DATA_SWITCH_STATUS
+import com.android.settingslib.spa.framework.common.LocalEntryDataProvider
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.LogEvent
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+
+@Composable
+fun logEntryEvent(): (event: LogEvent, extraData: Bundle) -> Unit {
+ val entryId = LocalEntryDataProvider.current.entryId ?: return { _, _ -> }
+ val arguments = LocalEntryDataProvider.current.arguments
+ return { event, extraData ->
+ SpaEnvironmentFactory.instance.logger.event(
+ entryId, event, category = LogCategory.VIEW, extraData = extraData.apply {
+ if (arguments != null) putAll(arguments)
+ }
+ )
+ }
+}
+
+@Composable
+fun wrapOnClickWithLog(onClick: (() -> Unit)?): (() -> Unit)? {
+ if (onClick == null) return null
+ val logEvent = logEntryEvent()
+ return {
+ logEvent(LogEvent.ENTRY_CLICK, bundleOf())
+ onClick()
+ }
+}
+
+@Composable
+fun wrapOnSwitchWithLog(onSwitch: ((checked: Boolean) -> Unit)?): ((checked: Boolean) -> Unit)? {
+ if (onSwitch == null) return null
+ val logEvent = logEntryEvent()
+ return {
+ logEvent(LogEvent.ENTRY_SWITCH, bundleOf(LOG_DATA_SWITCH_STATUS to it))
+ onSwitch(it)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/Flows.kt b/spa/src/com/android/settingslib/spa/framework/util/Flows.kt
new file mode 100644
index 00000000..83cb549b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/Flows.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.launch
+
+/**
+ * Returns a [Flow] whose values are a list which containing the results of applying the given
+ * [transform] function to each element in the original flow's list.
+ */
+inline fun Flow>.mapItem(crossinline transform: (T) -> R): Flow> =
+ map { list -> list.map(transform) }
+
+/**
+ * Returns a [Flow] whose values are a list which containing the results of asynchronously applying
+ * the given [transform] function to each element in the original flow's list.
+ */
+inline fun Flow>.asyncMapItem(crossinline transform: (T) -> R): Flow> =
+ map { list -> list.asyncMap(transform) }
+
+/**
+ * Returns a [Flow] whose values are a list containing only elements matching the given [predicate].
+ */
+inline fun Flow>.filterItem(crossinline predicate: (T) -> Boolean): Flow> =
+ map { list -> list.filter(predicate) }
+
+/**
+ * Delays the flow a little bit, wait the other flow's first value.
+ */
+fun Flow.waitFirst(otherFlow: Flow): Flow =
+ combine(otherFlow.take(1)) { value, _ -> value }
+
+
+/**
+ * Collects the latest value of given flow with a provided action with [LifecycleOwner].
+ */
+fun Flow.collectLatestWithLifecycle(
+ lifecycleOwner: LifecycleOwner,
+ minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
+ action: suspend (value: T) -> Unit,
+) {
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(minActiveState) {
+ collectLatest(action)
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/MessageFormats.kt b/spa/src/com/android/settingslib/spa/framework/util/MessageFormats.kt
new file mode 100644
index 00000000..edd30dd3
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/MessageFormats.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import android.content.Context
+import android.content.res.Resources
+import android.icu.text.MessageFormat
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.annotation.StringRes
+import java.util.Locale
+
+@RequiresApi(Build.VERSION_CODES.N)
+@SafeVarargs
+fun Context.formatString(@StringRes resId: Int, vararg arguments: Pair): String =
+ resources.formatString(resId, *arguments)
+
+@RequiresApi(Build.VERSION_CODES.N)
+@SafeVarargs
+fun Resources.formatString(@StringRes resId: Int, vararg arguments: Pair): String =
+ MessageFormat(getString(resId), Locale.getDefault(Locale.Category.FORMAT))
+ .format(mapOf(*arguments))
diff --git a/spa/src/com/android/settingslib/spa/framework/util/PageLogger.kt b/spa/src/com/android/settingslib/spa/framework/util/PageLogger.kt
new file mode 100644
index 00000000..a9e5e393
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/PageLogger.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import androidx.compose.runtime.Composable
+import androidx.core.os.bundleOf
+import com.android.settingslib.spa.framework.common.LOG_DATA_DISPLAY_NAME
+import com.android.settingslib.spa.framework.common.LOG_DATA_SESSION_NAME
+import com.android.settingslib.spa.framework.common.LogCategory
+import com.android.settingslib.spa.framework.common.LogEvent
+import com.android.settingslib.spa.framework.common.SettingsPage
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.compose.LifecycleEffect
+import com.android.settingslib.spa.framework.compose.LocalNavController
+import com.android.settingslib.spa.framework.compose.NavControllerWrapper
+
+@Composable
+internal fun SettingsPage.PageLogger() {
+ val navController = LocalNavController.current
+ LifecycleEffect(
+ onStart = { logPageEvent(LogEvent.PAGE_ENTER, navController) },
+ onStop = { logPageEvent(LogEvent.PAGE_LEAVE, navController) },
+ )
+}
+
+private fun SettingsPage.logPageEvent(event: LogEvent, navController: NavControllerWrapper) {
+ SpaEnvironmentFactory.instance.logger.event(
+ id = id,
+ event = event,
+ category = LogCategory.FRAMEWORK,
+ extraData = bundleOf(
+ LOG_DATA_DISPLAY_NAME to displayName,
+ LOG_DATA_SESSION_NAME to navController.sessionSourceName,
+ ).apply {
+ val normArguments = parameter.normalize(arguments)
+ if (normArguments != null) putAll(normArguments)
+ }
+ )
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/framework/util/Parameter.kt b/spa/src/com/android/settingslib/spa/framework/util/Parameter.kt
new file mode 100644
index 00000000..f0eeb13f
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/Parameter.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import android.os.Bundle
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavType
+
+const val RUNTIME_PARAM_PREFIX = "rt_"
+const val UNSET_PARAM_PREFIX = "unset_"
+const val UNSET_PARAM_VALUE = "[unset]"
+
+fun List.navRoute(): String {
+ return this.joinToString("") { argument -> "/{${argument.name}}" }
+}
+
+fun List.navLink(arguments: Bundle? = null): String {
+ return normalizeArgList(arguments).joinToString("") { arg -> "/$arg" }
+}
+
+fun List.normalizeArgList(
+ arguments: Bundle? = null,
+ eraseRuntimeValues: Boolean = false
+): List {
+ val argsArray = mutableListOf()
+ for (navArg in this) {
+ if (eraseRuntimeValues && navArg.isRuntimeParam()) continue
+ if (arguments == null || !arguments.containsKey(navArg.name)) {
+ argsArray.add(UNSET_PARAM_VALUE)
+ continue
+ }
+ when (navArg.argument.type) {
+ NavType.StringType -> {
+ argsArray.add(arguments.getString(navArg.name, ""))
+ }
+ NavType.IntType -> {
+ argsArray.add(arguments.getInt(navArg.name).toString())
+ }
+ }
+ }
+ return argsArray
+}
+
+fun List.normalize(
+ arguments: Bundle? = null,
+ eraseRuntimeValues: Boolean = false
+): Bundle? {
+ if (this.isEmpty()) return null
+ val normArgs = Bundle()
+ for (navArg in this) {
+ // Erase value of runtime parameters.
+ if (eraseRuntimeValues && navArg.isRuntimeParam()) {
+ normArgs.putString(navArg.name, null)
+ continue
+ }
+
+ when (navArg.argument.type) {
+ NavType.StringType -> {
+ val value = arguments?.getString(navArg.name)
+ if (value != null)
+ normArgs.putString(navArg.name, value)
+ else
+ normArgs.putString(UNSET_PARAM_PREFIX + navArg.name, null)
+ }
+ NavType.IntType -> {
+ if (arguments != null && arguments.containsKey(navArg.name))
+ normArgs.putInt(navArg.name, arguments.getInt(navArg.name))
+ else
+ normArgs.putString(UNSET_PARAM_PREFIX + navArg.name, null)
+ }
+ }
+ }
+ return normArgs
+}
+
+fun List.getStringArg(name: String, arguments: Bundle? = null): String? {
+ if (this.containsStringArg(name) && arguments != null) {
+ return arguments.getString(name)
+ }
+ return null
+}
+
+fun List.getIntArg(name: String, arguments: Bundle? = null): Int? {
+ if (this.containsIntArg(name) && arguments != null && arguments.containsKey(name)) {
+ return arguments.getInt(name)
+ }
+ return null
+}
+
+fun List.containsStringArg(name: String): Boolean {
+ for (navArg in this) {
+ if (navArg.argument.type == NavType.StringType && navArg.name == name) return true
+ }
+ return false
+}
+
+fun List.containsIntArg(name: String): Boolean {
+ for (navArg in this) {
+ if (navArg.argument.type == NavType.IntType && navArg.name == name) return true
+ }
+ return false
+}
+
+fun NamedNavArgument.isRuntimeParam(): Boolean {
+ return this.name.startsWith(RUNTIME_PARAM_PREFIX)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/SpaIntent.kt b/spa/src/com/android/settingslib/spa/framework/util/SpaIntent.kt
new file mode 100644
index 00000000..d8c35a36
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/SpaIntent.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import android.content.ComponentName
+import android.content.Intent
+import com.android.settingslib.spa.framework.common.SettingsEntry
+import com.android.settingslib.spa.framework.common.SettingsPage
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+
+const val SESSION_UNKNOWN = "unknown"
+const val SESSION_BROWSE = "browse"
+const val SESSION_SEARCH = "search"
+const val SESSION_SLICE = "slice"
+const val SESSION_EXTERNAL = "external"
+
+const val KEY_DESTINATION = "spaActivityDestination"
+const val KEY_HIGHLIGHT_ENTRY = "highlightEntry"
+const val KEY_SESSION_SOURCE_NAME = "sessionSource"
+
+val SPA_INTENT_RESERVED_KEYS = listOf(
+ KEY_DESTINATION,
+ KEY_HIGHLIGHT_ENTRY,
+ KEY_SESSION_SOURCE_NAME
+)
+
+private fun createBaseIntent(): Intent? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
+ return Intent().setComponent(ComponentName(context, browseActivityClass))
+}
+
+fun SettingsPage.createIntent(sessionName: String? = null): Intent? {
+ if (!isBrowsable()) return null
+ return createBaseIntent()?.appendSpaParams(
+ destination = buildRoute(),
+ sessionName = sessionName
+ )
+}
+
+fun SettingsEntry.createIntent(sessionName: String? = null): Intent? {
+ val sp = containerPage()
+ if (!sp.isBrowsable()) return null
+ return createBaseIntent()?.appendSpaParams(
+ destination = sp.buildRoute(),
+ entryId = id,
+ sessionName = sessionName
+ )
+}
+
+fun Intent.appendSpaParams(
+ destination: String? = null,
+ entryId: String? = null,
+ sessionName: String? = null
+): Intent {
+ return apply {
+ if (destination != null) putExtra(KEY_DESTINATION, destination)
+ if (entryId != null) putExtra(KEY_HIGHLIGHT_ENTRY, entryId)
+ if (sessionName != null) putExtra(KEY_SESSION_SOURCE_NAME, sessionName)
+ }
+}
+
+fun Intent.getDestination(): String? {
+ return getStringExtra(KEY_DESTINATION)
+}
+
+fun Intent.getEntryId(): String? {
+ return getStringExtra(KEY_HIGHLIGHT_ENTRY)
+}
+
+fun Intent.getSessionName(): String? {
+ return getStringExtra(KEY_SESSION_SOURCE_NAME)
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/StateFlowBridge.kt b/spa/src/com/android/settingslib/spa/framework/util/StateFlowBridge.kt
new file mode 100644
index 00000000..7842948b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/StateFlowBridge.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.framework.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filterNotNull
+
+/** A StateFlow holder which value could be set or sync from callback. */
+class StateFlowBridge {
+ private val stateFlow = MutableStateFlow(null)
+ val flow = stateFlow.filterNotNull()
+
+ fun setIfAbsent(value: T) {
+ if (stateFlow.value == null) {
+ stateFlow.value = value
+ }
+ }
+
+ @Composable
+ fun Sync(callback: () -> T) {
+ val value = callback()
+ LaunchedEffect(value) {
+ stateFlow.value = value
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/framework/util/UniqueId.kt b/spa/src/com/android/settingslib/spa/framework/util/UniqueId.kt
new file mode 100644
index 00000000..3b0ff7da
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/framework/util/UniqueId.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.framework.util
+
+import android.os.Bundle
+import androidx.navigation.NamedNavArgument
+import com.android.settingslib.spa.framework.common.SettingsPage
+
+fun genPageId(
+ sppName: String,
+ parameter: List = emptyList(),
+ arguments: Bundle? = null
+): String {
+ val normArguments = parameter.normalize(arguments, eraseRuntimeValues = true)
+ return "$sppName:${normArguments?.toString()}".toHashId()
+}
+
+fun genEntryId(
+ name: String,
+ owner: SettingsPage,
+ fromPage: SettingsPage? = null,
+ toPage: SettingsPage? = null
+): String {
+ return "$name:${owner.id}(${fromPage?.id}-${toPage?.id})".toHashId()
+}
+
+// TODO: implement a better hash function
+private fun String.toHashId(): String {
+ return this.hashCode().toUInt().toString(36)
+}
diff --git a/spa/src/com/android/settingslib/spa/lifecycle/FlowExt.kt b/spa/src/com/android/settingslib/spa/lifecycle/FlowExt.kt
new file mode 100644
index 00000000..1d3eb51e
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/lifecycle/FlowExt.kt
@@ -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 com.android.settingslib.spa.lifecycle
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.Flow
+
+@Composable
+fun Flow.collectAsCallbackWithLifecycle(): () -> T? {
+ val value by collectAsStateWithLifecycle(initialValue = null)
+ return { value }
+}
diff --git a/spa/src/com/android/settingslib/spa/livedata/LiveDataExt.kt b/spa/src/com/android/settingslib/spa/livedata/LiveDataExt.kt
new file mode 100644
index 00000000..3d330fe8
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/livedata/LiveDataExt.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.livedata
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.lifecycle.LiveData
+
+/**
+ * Starts observing this LiveData and represents its values via State and Callback.
+ *
+ * Every time there would be new value posted into the LiveData the returned State will be updated
+ * causing recomposition of every Callback usage.
+ */
+@Composable
+fun LiveData.observeAsCallback(): () -> T? {
+ val isAllowed by observeAsState()
+ return { isAllowed }
+}
diff --git a/spa/src/com/android/settingslib/spa/search/SpaSearchContract.kt b/spa/src/com/android/settingslib/spa/search/SpaSearchContract.kt
new file mode 100644
index 00000000..780933d3
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/search/SpaSearchContract.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.search
+
+/**
+ * Intent action used to identify SpaSearchProvider instances. This is used in the {@code
+ * } of a {@code }.
+ */
+const val PROVIDER_INTERFACE = "android.content.action.SPA_SEARCH_PROVIDER"
+
+/** ContentProvider path for search static data */
+const val SEARCH_STATIC_DATA = "search_static_data"
+
+/** ContentProvider path for search dynamic data */
+const val SEARCH_DYNAMIC_DATA = "search_dynamic_data"
+
+/** ContentProvider path for search immutable status */
+const val SEARCH_IMMUTABLE_STATUS = "search_immutable_status"
+
+/** ContentProvider path for search mutable status */
+const val SEARCH_MUTABLE_STATUS = "search_mutable_status"
+
+/** ContentProvider path for search static row */
+const val SEARCH_STATIC_ROW = "search_static_row"
+
+/** ContentProvider path for search dynamic row */
+const val SEARCH_DYNAMIC_ROW = "search_dynamic_row"
+
+
+/** Enum to define all column names in provider. */
+enum class ColumnEnum(val id: String) {
+ ENTRY_ID("entryId"),
+ ENTRY_LABEL("entryLabel"),
+ SEARCH_TITLE("searchTitle"),
+ SEARCH_KEYWORD("searchKw"),
+ SEARCH_PATH("searchPath"),
+ INTENT_TARGET_PACKAGE("intentTargetPackage"),
+ INTENT_TARGET_CLASS("intentTargetClass"),
+ INTENT_EXTRAS("intentExtras"),
+ SLICE_URI("sliceUri"),
+ ENTRY_DISABLED("entryDisabled"),
+}
+
+/** Enum to define all queries supported in the provider. */
+@SuppressWarnings("Immutable")
+enum class QueryEnum(
+ val queryPath: String,
+ val columnNames: List
+) {
+ SEARCH_STATIC_DATA_QUERY(
+ SEARCH_STATIC_DATA,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.SEARCH_TITLE,
+ ColumnEnum.SEARCH_KEYWORD,
+ ColumnEnum.SEARCH_PATH,
+ ColumnEnum.INTENT_TARGET_PACKAGE,
+ ColumnEnum.INTENT_TARGET_CLASS,
+ ColumnEnum.INTENT_EXTRAS,
+ ColumnEnum.SLICE_URI,
+ )
+ ),
+ SEARCH_DYNAMIC_DATA_QUERY(
+ SEARCH_DYNAMIC_DATA,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.SEARCH_TITLE,
+ ColumnEnum.SEARCH_KEYWORD,
+ ColumnEnum.SEARCH_PATH,
+ ColumnEnum.INTENT_TARGET_PACKAGE,
+ ColumnEnum.INTENT_TARGET_CLASS,
+ ColumnEnum.INTENT_EXTRAS,
+ ColumnEnum.SLICE_URI,
+ )
+ ),
+ SEARCH_IMMUTABLE_STATUS_DATA_QUERY(
+ SEARCH_IMMUTABLE_STATUS,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.ENTRY_DISABLED,
+ )
+ ),
+ SEARCH_MUTABLE_STATUS_DATA_QUERY(
+ SEARCH_MUTABLE_STATUS,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.ENTRY_DISABLED,
+ )
+ ),
+ SEARCH_STATIC_ROW_QUERY(
+ SEARCH_STATIC_ROW,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.SEARCH_TITLE,
+ ColumnEnum.SEARCH_KEYWORD,
+ ColumnEnum.SEARCH_PATH,
+ ColumnEnum.INTENT_TARGET_PACKAGE,
+ ColumnEnum.INTENT_TARGET_CLASS,
+ ColumnEnum.INTENT_EXTRAS,
+ ColumnEnum.SLICE_URI,
+ ColumnEnum.ENTRY_DISABLED,
+ )
+ ),
+ SEARCH_DYNAMIC_ROW_QUERY(
+ SEARCH_DYNAMIC_ROW,
+ listOf(
+ ColumnEnum.ENTRY_ID,
+ ColumnEnum.ENTRY_LABEL,
+ ColumnEnum.SEARCH_TITLE,
+ ColumnEnum.SEARCH_KEYWORD,
+ ColumnEnum.SEARCH_PATH,
+ ColumnEnum.INTENT_TARGET_PACKAGE,
+ ColumnEnum.INTENT_TARGET_CLASS,
+ ColumnEnum.INTENT_EXTRAS,
+ ColumnEnum.SLICE_URI,
+ ColumnEnum.ENTRY_DISABLED,
+ )
+ ),
+}
diff --git a/spa/src/com/android/settingslib/spa/search/SpaSearchProvider.kt b/spa/src/com/android/settingslib/spa/search/SpaSearchProvider.kt
new file mode 100644
index 00000000..eacb28c2
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/search/SpaSearchProvider.kt
@@ -0,0 +1,277 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.search
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.UriMatcher
+import android.content.pm.ProviderInfo
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Log
+import androidx.annotation.VisibleForTesting
+import com.android.settingslib.spa.framework.common.SettingsEntry
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.util.SESSION_SEARCH
+import com.android.settingslib.spa.framework.util.createIntent
+import com.android.settingslib.spa.slice.fromEntry
+
+
+private const val TAG = "SpaSearchProvider"
+
+/**
+ * The content provider to return entry related data, which can be used for search and hierarchy.
+ * One can query the provider result by:
+ * $ adb shell content query --uri content:///
+ * For gallery, AuthorityPath = com.android.spa.gallery.search.provider
+ * For Settings, AuthorityPath = com.android.settings.spa.search.provider
+ * Some examples:
+ * $ adb shell content query --uri content:///search_static_data
+ * $ adb shell content query --uri content:///search_dynamic_data
+ * $ adb shell content query --uri content:///search_immutable_status
+ * $ adb shell content query --uri content:///search_mutable_status
+ * $ adb shell content query --uri content:///search_static_row
+ * $ adb shell content query --uri content:///search_dynamic_row
+ */
+class SpaSearchProvider : ContentProvider() {
+ private val spaEnvironment get() = SpaEnvironmentFactory.instance
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
+
+ private val queryMatchCode = mapOf(
+ SEARCH_STATIC_DATA to 301,
+ SEARCH_DYNAMIC_DATA to 302,
+ SEARCH_MUTABLE_STATUS to 303,
+ SEARCH_IMMUTABLE_STATUS to 304,
+ SEARCH_STATIC_ROW to 305,
+ SEARCH_DYNAMIC_ROW to 306
+ )
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ TODO("Implement this to handle requests to delete one or more rows")
+ }
+
+ override fun getType(uri: Uri): String? {
+ TODO(
+ "Implement this to handle requests for the MIME type of the data" +
+ "at the given URI"
+ )
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ TODO("Implement this to handle requests to insert a new row.")
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int {
+ TODO("Implement this to handle requests to update one or more rows.")
+ }
+
+ override fun onCreate(): Boolean {
+ Log.d(TAG, "onCreate")
+ return true
+ }
+
+ override fun attachInfo(context: Context?, info: ProviderInfo?) {
+ if (info != null) {
+ for (entry in queryMatchCode) {
+ uriMatcher.addURI(info.authority, entry.key, entry.value)
+ }
+ }
+ super.attachInfo(context, info)
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ return try {
+ when (uriMatcher.match(uri)) {
+ queryMatchCode[SEARCH_STATIC_DATA] -> querySearchStaticData()
+ queryMatchCode[SEARCH_DYNAMIC_DATA] -> querySearchDynamicData()
+ queryMatchCode[SEARCH_MUTABLE_STATUS] ->
+ querySearchMutableStatusData()
+ queryMatchCode[SEARCH_IMMUTABLE_STATUS] ->
+ querySearchImmutableStatusData()
+ queryMatchCode[SEARCH_STATIC_ROW] -> querySearchStaticRow()
+ queryMatchCode[SEARCH_DYNAMIC_ROW] -> querySearchDynamicRow()
+ else -> throw UnsupportedOperationException("Unknown Uri $uri")
+ }
+ } catch (e: UnsupportedOperationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Provider querying exception:", e)
+ null
+ }
+ }
+
+ @VisibleForTesting
+ internal fun querySearchImmutableStatusData(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_IMMUTABLE_STATUS_DATA_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || entry.hasMutableStatus) continue
+ fetchStatusData(entry, cursor)
+ }
+ return cursor
+ }
+
+ @VisibleForTesting
+ internal fun querySearchMutableStatusData(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_MUTABLE_STATUS_DATA_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || !entry.hasMutableStatus) continue
+ fetchStatusData(entry, cursor)
+ }
+ return cursor
+ }
+
+ @VisibleForTesting
+ internal fun querySearchStaticData(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_STATIC_DATA_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || entry.isSearchDataDynamic) continue
+ fetchSearchData(entry, cursor)
+ }
+ return cursor
+ }
+
+ @VisibleForTesting
+ internal fun querySearchDynamicData(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_DYNAMIC_DATA_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || !entry.isSearchDataDynamic) continue
+ fetchSearchData(entry, cursor)
+ }
+ return cursor
+ }
+
+ @VisibleForTesting
+ fun querySearchStaticRow(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_STATIC_ROW_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || entry.isSearchDataDynamic || entry.hasMutableStatus)
+ continue
+ fetchSearchRow(entry, cursor)
+ }
+ return cursor
+ }
+
+ @VisibleForTesting
+ fun querySearchDynamicRow(): Cursor {
+ val entryRepository by spaEnvironment.entryRepository
+ val cursor = MatrixCursor(QueryEnum.SEARCH_DYNAMIC_ROW_QUERY.getColumns())
+ for (entry in entryRepository.getAllEntries()) {
+ if (!entry.isAllowSearch || (!entry.isSearchDataDynamic && !entry.hasMutableStatus))
+ continue
+ fetchSearchRow(entry, cursor)
+ }
+ return cursor
+ }
+
+
+ private fun fetchSearchData(entry: SettingsEntry, cursor: MatrixCursor) {
+ val entryRepository by spaEnvironment.entryRepository
+
+ // Fetch search data. We can add runtime arguments later if necessary
+ val searchData = entry.getSearchData() ?: return
+ val intent = entry.createIntent(SESSION_SEARCH)
+ val row = cursor.newRow().add(ColumnEnum.ENTRY_ID.id, entry.id)
+ .add(ColumnEnum.ENTRY_LABEL.id, entry.label)
+ .add(ColumnEnum.SEARCH_TITLE.id, searchData.title)
+ .add(ColumnEnum.SEARCH_KEYWORD.id, searchData.keyword)
+ .add(
+ ColumnEnum.SEARCH_PATH.id,
+ entryRepository.getEntryPathWithTitle(entry.id, searchData.title)
+ )
+ intent?.let {
+ row.add(ColumnEnum.INTENT_TARGET_PACKAGE.id, spaEnvironment.appContext.packageName)
+ .add(ColumnEnum.INTENT_TARGET_CLASS.id, spaEnvironment.browseActivityClass?.name)
+ .add(ColumnEnum.INTENT_EXTRAS.id, marshall(intent.extras))
+ }
+ if (entry.hasSliceSupport)
+ row.add(
+ ColumnEnum.SLICE_URI.id, Uri.Builder()
+ .fromEntry(entry, spaEnvironment.sliceProviderAuthorities)
+ )
+ }
+
+ private fun fetchStatusData(entry: SettingsEntry, cursor: MatrixCursor) {
+ // Fetch status data. We can add runtime arguments later if necessary
+ val statusData = entry.getStatusData() ?: return
+ cursor.newRow()
+ .add(ColumnEnum.ENTRY_ID.id, entry.id)
+ .add(ColumnEnum.ENTRY_LABEL.id, entry.label)
+ .add(ColumnEnum.ENTRY_DISABLED.id, statusData.isDisabled)
+ }
+
+ private fun fetchSearchRow(entry: SettingsEntry, cursor: MatrixCursor) {
+ val entryRepository by spaEnvironment.entryRepository
+
+ // Fetch search data. We can add runtime arguments later if necessary
+ val searchData = entry.getSearchData() ?: return
+ val intent = entry.createIntent(SESSION_SEARCH)
+ val row = cursor.newRow().add(ColumnEnum.ENTRY_ID.id, entry.id)
+ .add(ColumnEnum.ENTRY_LABEL.id, entry.label)
+ .add(ColumnEnum.SEARCH_TITLE.id, searchData.title)
+ .add(ColumnEnum.SEARCH_KEYWORD.id, searchData.keyword)
+ .add(
+ ColumnEnum.SEARCH_PATH.id,
+ entryRepository.getEntryPathWithTitle(entry.id, searchData.title)
+ )
+ intent?.let {
+ row.add(ColumnEnum.INTENT_TARGET_PACKAGE.id, spaEnvironment.appContext.packageName)
+ .add(ColumnEnum.INTENT_TARGET_CLASS.id, spaEnvironment.browseActivityClass?.name)
+ .add(ColumnEnum.INTENT_EXTRAS.id, marshall(intent.extras))
+ }
+ if (entry.hasSliceSupport)
+ row.add(
+ ColumnEnum.SLICE_URI.id, Uri.Builder()
+ .fromEntry(entry, spaEnvironment.sliceProviderAuthorities)
+ )
+ // Fetch status data. We can add runtime arguments later if necessary
+ val statusData = entry.getStatusData() ?: return
+ row.add(ColumnEnum.ENTRY_DISABLED.id, statusData.isDisabled)
+ }
+
+ private fun QueryEnum.getColumns(): Array {
+ return columnNames.map { it.id }.toTypedArray()
+ }
+
+ private fun marshall(parcelable: Parcelable?): ByteArray? {
+ if (parcelable == null) return null
+ val parcel = Parcel.obtain()
+ parcelable.writeToParcel(parcel, 0)
+ val bytes = parcel.marshall()
+ parcel.recycle()
+ return bytes
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt b/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt
new file mode 100644
index 00000000..7a4750df
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice
+
+import android.net.Uri
+import android.util.Log
+import com.android.settingslib.spa.framework.common.EntrySliceData
+import com.android.settingslib.spa.framework.common.SettingsEntryRepository
+import com.android.settingslib.spa.framework.util.getEntryId
+
+private const val TAG = "SliceDataRepository"
+
+class SettingsSliceDataRepository(private val entryRepository: SettingsEntryRepository) {
+ // The map of slice uri to its EntrySliceData, a.k.a. LiveData
+ private val sliceDataMap: MutableMap = mutableMapOf()
+
+ // Note: mark this function synchronized, so that we can get the same livedata during the
+ // whole lifecycle of a Slice.
+ @Synchronized
+ fun getOrBuildSliceData(sliceUri: Uri): EntrySliceData? {
+ val sliceString = sliceUri.getSliceId() ?: return null
+ return sliceDataMap[sliceString] ?: buildLiveDataImpl(sliceUri)?.let {
+ sliceDataMap[sliceString] = it
+ it
+ }
+ }
+
+ fun getActiveSliceData(sliceUri: Uri): EntrySliceData? {
+ val sliceString = sliceUri.getSliceId() ?: return null
+ val sliceData = sliceDataMap[sliceString] ?: return null
+ return if (sliceData.isActive()) sliceData else null
+ }
+
+ private fun buildLiveDataImpl(sliceUri: Uri): EntrySliceData? {
+ Log.d(TAG, "buildLiveData: $sliceUri")
+
+ val entryId = sliceUri.getEntryId() ?: return null
+ val entry = entryRepository.getEntry(entryId) ?: return null
+ if (!entry.hasSliceSupport) return null
+ val arguments = sliceUri.getRuntimeArguments()
+ return entry.getSliceData(runtimeArguments = arguments, sliceUri = sliceUri)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt b/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt
new file mode 100644
index 00000000..f3628903
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import com.android.settingslib.spa.framework.common.SettingsEntry
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.framework.util.KEY_DESTINATION
+import com.android.settingslib.spa.framework.util.KEY_HIGHLIGHT_ENTRY
+import com.android.settingslib.spa.framework.util.SESSION_SLICE
+import com.android.settingslib.spa.framework.util.SPA_INTENT_RESERVED_KEYS
+import com.android.settingslib.spa.framework.util.appendSpaParams
+import com.android.settingslib.spa.framework.util.getDestination
+import com.android.settingslib.spa.framework.util.getEntryId
+
+// Defines SliceUri, which contains special query parameters:
+// -- KEY_DESTINATION: The route that this slice is navigated to.
+// -- KEY_HIGHLIGHT_ENTRY: The entry id of this slice
+// Other parameters can considered as runtime parameters.
+// Use {entryId, runtimeParams} as the unique Id of this Slice.
+typealias SliceUri = Uri
+
+fun SliceUri.getEntryId(): String? {
+ return getQueryParameter(KEY_HIGHLIGHT_ENTRY)
+}
+
+fun SliceUri.getDestination(): String? {
+ return getQueryParameter(KEY_DESTINATION)
+}
+
+fun SliceUri.getRuntimeArguments(): Bundle {
+ val params = Bundle()
+ for (queryName in queryParameterNames) {
+ if (SPA_INTENT_RESERVED_KEYS.contains(queryName)) continue
+ params.putString(queryName, getQueryParameter(queryName))
+ }
+ return params
+}
+
+fun SliceUri.getSliceId(): String? {
+ val entryId = getEntryId() ?: return null
+ val params = getRuntimeArguments()
+ return "${entryId}_$params"
+}
+
+fun Uri.Builder.appendSpaParams(
+ destination: String? = null,
+ entryId: String? = null,
+ runtimeArguments: Bundle? = null
+): Uri.Builder {
+ if (destination != null) appendQueryParameter(KEY_DESTINATION, destination)
+ if (entryId != null) appendQueryParameter(KEY_HIGHLIGHT_ENTRY, entryId)
+ if (runtimeArguments != null) {
+ for (key in runtimeArguments.keySet()) {
+ appendQueryParameter(key, runtimeArguments.getString(key, ""))
+ }
+ }
+ return this
+}
+
+fun Uri.Builder.fromEntry(
+ entry: SettingsEntry,
+ authority: String?,
+ runtimeArguments: Bundle? = null
+): Uri.Builder {
+ if (authority == null) return this
+ val sp = entry.containerPage()
+ return scheme("content").authority(authority).appendSpaParams(
+ destination = sp.buildRoute(),
+ entryId = entry.id,
+ runtimeArguments = runtimeArguments
+ )
+}
+
+fun SliceUri.createBroadcastPendingIntent(): PendingIntent? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ val sliceBroadcastClass =
+ SpaEnvironmentFactory.instance.sliceBroadcastReceiverClass ?: return null
+ val entryId = getEntryId() ?: return null
+ return createBroadcastPendingIntent(context, sliceBroadcastClass, entryId)
+}
+
+fun SliceUri.createBrowsePendingIntent(): PendingIntent? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
+ val destination = getDestination() ?: return null
+ val entryId = getEntryId()
+ return createBrowsePendingIntent(context, browseActivityClass, destination, entryId)
+}
+
+fun Intent.createBrowsePendingIntent(): PendingIntent? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
+ val destination = getDestination() ?: return null
+ val entryId = getEntryId()
+ return createBrowsePendingIntent(context, browseActivityClass, destination, entryId)
+}
+
+private fun createBrowsePendingIntent(
+ context: Context,
+ browseActivityClass: Class,
+ destination: String,
+ entryId: String?
+): PendingIntent {
+ val intent = Intent().setComponent(ComponentName(context, browseActivityClass))
+ .appendSpaParams(destination, entryId, SESSION_SLICE)
+ .apply {
+ // Set both extra and data (which is a Uri) in Slice Intent:
+ // 1) extra is used in SPA navigation framework
+ // 2) data is used in Slice framework
+ data = Uri.Builder().appendSpaParams(destination, entryId).build()
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+}
+
+private fun createBroadcastPendingIntent(
+ context: Context,
+ sliceBroadcastClass: Class,
+ entryId: String
+): PendingIntent {
+ val intent = Intent().setComponent(ComponentName(context, sliceBroadcastClass))
+ .apply { data = Uri.Builder().appendSpaParams(entryId = entryId).build() }
+ return PendingIntent.getBroadcast(
+ context, 0 /* requestCode */, intent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/SpaSliceBroadcastReceiver.kt b/spa/src/com/android/settingslib/spa/slice/SpaSliceBroadcastReceiver.kt
new file mode 100644
index 00000000..39cb4318
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/SpaSliceBroadcastReceiver.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+
+class SpaSliceBroadcastReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val sliceRepository by SpaEnvironmentFactory.instance.sliceDataRepository
+ val sliceUri = intent?.data ?: return
+ val sliceData = sliceRepository.getActiveSliceData(sliceUri) ?: return
+ sliceData.doAction()
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/SpaSliceProvider.kt b/spa/src/com/android/settingslib/spa/slice/SpaSliceProvider.kt
new file mode 100644
index 00000000..3496f02a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/SpaSliceProvider.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice
+
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.Observer
+import androidx.slice.Slice
+import androidx.slice.SliceProvider
+import com.android.settingslib.spa.framework.common.EntrySliceData
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+
+private const val TAG = "SpaSliceProvider"
+
+class SpaSliceProvider : SliceProvider(), Observer {
+ private fun getOrPutSliceData(sliceUri: Uri): EntrySliceData? {
+ if (!SpaEnvironmentFactory.isReady()) return null
+ val sliceRepository by SpaEnvironmentFactory.instance.sliceDataRepository
+ return sliceRepository.getOrBuildSliceData(sliceUri)
+ }
+
+ override fun onBindSlice(sliceUri: Uri): Slice? {
+ if (context == null) return null
+ Log.d(TAG, "onBindSlice: $sliceUri")
+ return getOrPutSliceData(sliceUri)?.value
+ }
+
+ override fun onSlicePinned(sliceUri: Uri) {
+ Log.d(TAG, "onSlicePinned: $sliceUri")
+ super.onSlicePinned(sliceUri)
+ val sliceLiveData = getOrPutSliceData(sliceUri) ?: return
+ runBlocking {
+ withContext(Dispatchers.Main) {
+ sliceLiveData.observeForever(this@SpaSliceProvider)
+ }
+ }
+ }
+
+ override fun onSliceUnpinned(sliceUri: Uri) {
+ Log.d(TAG, "onSliceUnpinned: $sliceUri")
+ super.onSliceUnpinned(sliceUri)
+ val sliceLiveData = getOrPutSliceData(sliceUri) ?: return
+ runBlocking {
+ withContext(Dispatchers.Main) {
+ sliceLiveData.removeObserver(this@SpaSliceProvider)
+ }
+ }
+ }
+
+ override fun onChanged(value: Slice?) {
+ val uri = value?.uri ?: return
+ Log.d(TAG, "onChanged: $uri")
+ context?.contentResolver?.notifyChange(uri, null)
+ }
+
+ override fun onCreateSliceProvider(): Boolean {
+ Log.d(TAG, "onCreateSliceProvider")
+ return true
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt b/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt
new file mode 100644
index 00000000..ee24a09d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice.presenter
+
+import android.net.Uri
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.slice.widget.SliceLiveData
+import androidx.slice.widget.SliceView
+
+@Composable
+fun SliceDemo(sliceUri: Uri) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val sliceData = remember {
+ SliceLiveData.fromUri(context, sliceUri)
+ }
+
+ HorizontalDivider()
+ AndroidView(
+ factory = { localContext ->
+ val view = SliceView(localContext)
+ view.setShowTitleItems(true)
+ view.isScrollable = false
+ view
+ },
+ update = { view -> sliceData.observe(lifecycleOwner, view) }
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt b/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt
new file mode 100644
index 00000000..e4a73863
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.slice.provider
+
+import android.app.PendingIntent
+import android.content.Context
+import android.net.Uri
+import androidx.core.R
+import androidx.core.graphics.drawable.IconCompat
+import androidx.slice.Slice
+import androidx.slice.SliceManager
+import androidx.slice.builders.ListBuilder
+import androidx.slice.builders.SliceAction
+import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
+import com.android.settingslib.spa.slice.createBroadcastPendingIntent
+import com.android.settingslib.spa.slice.createBrowsePendingIntent
+
+fun createDemoBrowseSlice(sliceUri: Uri, title: String, summary: String): Slice? {
+ val intent = sliceUri.createBrowsePendingIntent() ?: return null
+ return createDemoSlice(sliceUri, title, summary, intent)
+}
+
+fun createDemoActionSlice(sliceUri: Uri, title: String, summary: String): Slice? {
+ val intent = sliceUri.createBroadcastPendingIntent() ?: return null
+ return createDemoSlice(sliceUri, title, summary, intent)
+}
+
+fun createDemoSlice(sliceUri: Uri, title: String, summary: String, intent: PendingIntent): Slice? {
+ val context = SpaEnvironmentFactory.instance.appContext
+ if (!SliceManager.getInstance(context).pinnedSlices.contains(sliceUri)) return null
+ return ListBuilder(context, sliceUri, ListBuilder.INFINITY)
+ .addRow(ListBuilder.RowBuilder().apply {
+ setPrimaryAction(createSliceAction(context, intent))
+ setTitle(title)
+ setSubtitle(summary)
+ }).build()
+}
+
+private fun createSliceAction(context: Context, intent: PendingIntent): SliceAction {
+ return SliceAction.create(
+ intent,
+ IconCompat.createWithResource(context, R.drawable.notification_action_background),
+ ListBuilder.ICON_IMAGE,
+ "Enter app"
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/button/ActionButtons.kt b/spa/src/com/android/settingslib/spa/widget/button/ActionButtons.kt
new file mode 100644
index 00000000..979cf3bd
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/button/ActionButtons.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.button
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsShape
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.theme.divider
+import androidx.compose.material.icons.automirrored.outlined.Launch
+
+data class ActionButton(
+ val text: String,
+ val imageVector: ImageVector,
+ val enabled: Boolean = true,
+ val onClick: () -> Unit,
+)
+
+@Composable
+fun ActionButtons(actionButtons: List) {
+ Row(
+ Modifier
+ .padding(SettingsDimension.buttonPadding)
+ .clip(SettingsShape.CornerExtraLarge)
+ .height(IntrinsicSize.Min)
+ ) {
+ for ((index, actionButton) in actionButtons.withIndex()) {
+ if (index > 0) ButtonDivider()
+ ActionButton(actionButton)
+ }
+ }
+}
+
+@Composable
+private fun RowScope.ActionButton(actionButton: ActionButton) {
+ FilledTonalButton(
+ onClick = actionButton.onClick,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ enabled = actionButton.enabled,
+ // Because buttons could appear, disappear or change positions, reset the interaction source
+ // to prevent highlight the wrong button.
+ interactionSource = remember(actionButton) { MutableInteractionSource() },
+ shape = RectangleShape,
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = SettingsTheme.colorScheme.surface,
+ contentColor = SettingsTheme.colorScheme.categoryTitle,
+ disabledContainerColor = SettingsTheme.colorScheme.surface,
+ ),
+ contentPadding = PaddingValues(horizontal = 4.dp, vertical = 20.dp),
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = actionButton.imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(SettingsDimension.itemIconSize),
+ )
+ Box(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = actionButton.text,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ButtonDivider() {
+ Box(
+ Modifier
+ .width(1.dp)
+ .background(color = MaterialTheme.colorScheme.divider)
+ )
+}
+
+@Preview
+@Composable
+private fun ActionButtonsPreview() {
+ SettingsTheme {
+ ActionButtons(
+ listOf(
+ ActionButton(text = "Open", imageVector = Icons.AutoMirrored.Outlined.Launch) {},
+ ActionButton(text = "Uninstall", imageVector = Icons.Outlined.Delete) {},
+ ActionButton(text = "Force stop", imageVector = Icons.Outlined.WarningAmber) {},
+ )
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/card/CardModel.kt b/spa/src/com/android/settingslib/spa/widget/card/CardModel.kt
new file mode 100644
index 00000000..8100fd58
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/card/CardModel.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.card
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class CardButton(
+ val text: String,
+ val contentDescription: String? = null,
+ val onClick: () -> Unit,
+)
+
+data class CardModel(
+ val title: String,
+ val text: String,
+ val imageVector: ImageVector? = null,
+ val isVisible: () -> Boolean = { true },
+
+ /**
+ * A dismiss button will be displayed if this is not null.
+ *
+ * And this callback will be called when user clicks the button.
+ */
+ val onDismiss: (() -> Unit)? = null,
+
+ val buttons: List = emptyList(),
+
+ /** If specified, this color will be used to tint the icon and the buttons. */
+ val tintColor: Color = Color.Unspecified,
+
+ /** If specified, this color will be used to tint the icon and the buttons. */
+ val containerColor: Color = Color.Unspecified,
+
+ val onClick: (() -> Unit)? = null,
+)
diff --git a/spa/src/com/android/settingslib/spa/widget/card/SettingsCard.kt b/spa/src/com/android/settingslib/spa/widget/card/SettingsCard.kt
new file mode 100644
index 00000000..621825a8
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/card/SettingsCard.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.card
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.debug.UiModePreviews
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsShape.CornerExtraLarge
+import com.android.settingslib.spa.framework.theme.SettingsShape.CornerExtraSmall
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.ui.SettingsBody
+import com.android.settingslib.spa.widget.ui.SettingsTitle
+
+@Composable
+fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
+ Card(
+ shape = CornerExtraLarge,
+ colors = CardDefaults.cardColors(
+ containerColor = Color.Transparent,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = SettingsDimension.itemPaddingEnd,
+ vertical = SettingsDimension.itemPaddingAround,
+ ),
+ content = content,
+ )
+}
+
+@Composable
+fun SettingsCardContent(
+ containerColor: Color = Color.Unspecified,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Card(
+ shape = CornerExtraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = containerColor.takeOrElse { SettingsTheme.colorScheme.surface },
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 1.dp),
+ content = content,
+ )
+}
+
+@Composable
+fun SettingsCard(model: CardModel) {
+ SettingsCard {
+ SettingsCardImpl(model)
+ }
+}
+
+@Composable
+internal fun SettingsCardImpl(model: CardModel) {
+ AnimatedVisibility(visible = model.isVisible()) {
+ SettingsCardContent(containerColor = model.containerColor) {
+ Column(
+ modifier = (model.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier)
+ .padding(
+ horizontal = SettingsDimension.dialogItemPaddingHorizontal,
+ vertical = SettingsDimension.itemPaddingAround,
+ ),
+ verticalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingAround)
+ ) {
+ CardHeader(model.imageVector, model.tintColor, model.onDismiss)
+ SettingsTitle(model.title)
+ SettingsBody(model.text)
+ Buttons(model.buttons, model.tintColor)
+ }
+ }
+ }
+}
+
+@Composable
+fun CardHeader(imageVector: ImageVector?, iconColor: Color, onDismiss: (() -> Unit)? = null) {
+ if (imageVector != null || onDismiss != null) {
+ Spacer(Modifier.height(SettingsDimension.buttonPaddingVertical))
+ }
+ Row(Modifier.fillMaxWidth()) {
+ CardIcon(imageVector, iconColor)
+ Spacer(modifier = Modifier.weight(1f))
+ DismissButton(onDismiss)
+ }
+}
+
+@Composable
+private fun CardIcon(imageVector: ImageVector?, color: Color) {
+ if (imageVector != null) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(SettingsDimension.itemIconSize),
+ tint = color.takeOrElse { MaterialTheme.colorScheme.primary },
+ )
+ }
+}
+
+@Composable
+private fun DismissButton(onDismiss: (() -> Unit)?) {
+ if (onDismiss == null) return
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.size(SettingsDimension.itemIconSize)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = stringResource(
+ androidx.compose.material3.R.string.m3c_snackbar_dismiss
+ ),
+ modifier = Modifier.padding(SettingsDimension.paddingSmall),
+ )
+ }
+ }
+}
+
+@Composable
+private fun Buttons(buttons: List, color: Color) {
+ if (buttons.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = SettingsDimension.itemPaddingEnd,
+ alignment = Alignment.End,
+ ),
+ ) {
+ for (button in buttons) {
+ Button(button, color)
+ }
+ }
+ } else {
+ Spacer(Modifier.height(SettingsDimension.itemPaddingAround))
+ }
+}
+
+@Composable
+private fun Button(button: CardButton, color: Color) {
+ TextButton(
+ onClick = button.onClick,
+ modifier =
+ Modifier.semantics { button.contentDescription?.let { this.contentDescription = it } }
+ ) {
+ Text(text = button.text, color = color)
+ }
+}
+
+@UiModePreviews
+@Composable
+private fun SettingsCardPreview() {
+ SettingsTheme {
+ SettingsCard(
+ CardModel(
+ title = "Lorem ipsum",
+ text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ imageVector = Icons.Outlined.WarningAmber,
+ buttons = listOf(
+ CardButton(text = "Action") {},
+ )
+ )
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/card/SettingsCollapsibleCard.kt b/spa/src/com/android/settingslib/spa/widget/card/SettingsCollapsibleCard.kt
new file mode 100644
index 00000000..c34df653
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/card/SettingsCollapsibleCard.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.card
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Error
+import androidx.compose.material.icons.outlined.PowerOff
+import androidx.compose.material.icons.outlined.Shield
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.debug.UiModePreviews
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsShape
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.ui.ExpandIcon
+import com.android.settingslib.spa.widget.ui.SettingsDialogItem
+import com.android.settingslib.spa.widget.ui.SettingsTitleSmall
+
+@Composable
+fun SettingsCollapsibleCard(
+ title: String,
+ imageVector: ImageVector,
+ models: List,
+) {
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ SettingsCard {
+ SettingsCardContent {
+ Header(title, imageVector, models.count { it.isVisible() }, expanded) { expanded = it }
+ }
+ AnimatedVisibility(expanded) {
+ Column {
+ for (model in models) {
+ SettingsCardImpl(model)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Header(
+ title: String,
+ imageVector: ImageVector,
+ cardCount: Int,
+ expanded: Boolean,
+ setExpanded: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { setExpanded(!expanded) }
+ .padding(
+ horizontal = SettingsDimension.itemPaddingStart,
+ vertical = SettingsDimension.itemPaddingVertical,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingStart),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(SettingsDimension.itemIconSize),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Box(modifier = Modifier.weight(1f)) {
+ SettingsTitleSmall(title, useMediumWeight = true)
+ }
+ CardCount(cardCount, expanded)
+ }
+}
+
+@Composable
+private fun CardCount(modelSize: Int, expanded: Boolean) {
+ Surface(
+ shape = SettingsShape.CornerExtraLarge,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ Row(
+ modifier = Modifier.padding(SettingsDimension.paddingSmall),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(modifier = Modifier.padding(SettingsDimension.paddingSmall))
+ SettingsDialogItem(modelSize.toString())
+ ExpandIcon(expanded)
+ }
+ }
+}
+
+@UiModePreviews
+@Composable
+private fun SettingsCollapsibleCardPreview() {
+ SettingsTheme {
+ SettingsCollapsibleCard(
+ title = "More alerts",
+ imageVector = Icons.Outlined.Error,
+ models = listOf(
+ CardModel(
+ title = "Lorem ipsum",
+ text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ imageVector = Icons.Outlined.PowerOff,
+ buttons = listOf(
+ CardButton(text = "Action") {},
+ )
+ ),
+ CardModel(
+ title = "Lorem ipsum",
+ text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ imageVector = Icons.Outlined.Shield,
+ buttons = listOf(
+ CardButton(text = "Action") {},
+ )
+ )
+ )
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt b/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt
new file mode 100644
index 00000000..e7782556
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.chart
+
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.android.settingslib.spa.framework.theme.divider
+import com.github.mikephil.charting.charts.BarChart
+import com.github.mikephil.charting.components.XAxis
+import com.github.mikephil.charting.components.YAxis
+import com.github.mikephil.charting.components.YAxis.AxisDependency
+import com.github.mikephil.charting.data.BarData
+import com.github.mikephil.charting.data.BarDataSet
+import com.github.mikephil.charting.data.BarEntry
+import com.github.mikephil.charting.formatter.IAxisValueFormatter
+import com.github.mikephil.charting.formatter.ValueFormatter
+
+/**
+ * The chart settings model for [BarChart].
+ */
+interface BarChartModel {
+ /**
+ * The chart data list for [BarChart].
+ */
+ val chartDataList: List
+
+ /**
+ * The color list for [BarChart].
+ */
+ val colors: List
+
+ /**
+ * The label text formatter for x value.
+ */
+ val xValueFormatter: IAxisValueFormatter?
+ get() = null
+
+ /**
+ * The label text formatter for y value.
+ */
+ val yValueFormatter: IAxisValueFormatter?
+ get() = null
+
+ /**
+ * The minimum value for y-axis.
+ */
+ val yAxisMinValue: Float
+ get() = 0f
+
+ /**
+ * The maximum value for y-axis.
+ */
+ val yAxisMaxValue: Float
+ get() = 1f
+
+ /**
+ * The label count for y-axis.
+ */
+ val yAxisLabelCount: Int
+ get() = 3
+
+ /** If set to true, touch gestures are enabled on the [BarChart]. */
+ val enableBarchartTouch: Boolean
+ get() = true
+
+ /** The renderer provider for x-axis. */
+ val xAxisRendererProvider: XAxisRendererProvider?
+ get() = null
+}
+
+data class BarChartData(
+ var x: Float,
+ var y: List,
+)
+
+@Composable
+fun BarChart(barChartModel: BarChartModel) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .height(170.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ val colorScheme = MaterialTheme.colorScheme
+ val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
+ val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
+ Crossfade(
+ targetState = barChartModel.chartDataList,
+ label = "chartDataList",
+ ) { barChartData ->
+ AndroidView(
+ factory = { context ->
+ BarChart(context).apply {
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ description.isEnabled = false
+ legend.isEnabled = false
+ extraBottomOffset = 4f
+ setScaleEnabled(false)
+ setTouchEnabled(barChartModel.enableBarchartTouch)
+
+ xAxis.apply {
+ position = XAxis.XAxisPosition.BOTTOM
+ setDrawAxisLine(false)
+ setDrawGridLines(false)
+ textColor = labelTextColor
+ textSize = labelTextSize
+ valueFormatter = barChartModel.xValueFormatter as ValueFormatter?
+ yOffset = 10f
+ }
+
+ barChartModel.xAxisRendererProvider?.let {
+ setXAxisRenderer(
+ it.provideXAxisRenderer(
+ getViewPortHandler(),
+ getXAxis(),
+ getTransformer(YAxis.AxisDependency.LEFT)
+ )
+ )
+ }
+
+ axisLeft.apply {
+ axisMaximum = barChartModel.yAxisMaxValue
+ axisMinimum = barChartModel.yAxisMinValue
+ isEnabled = false
+ }
+
+ axisRight.apply {
+ axisMaximum = barChartModel.yAxisMaxValue
+ axisMinimum = barChartModel.yAxisMinValue
+ gridColor = colorScheme.divider.toArgb()
+ setDrawAxisLine(false)
+ setLabelCount(barChartModel.yAxisLabelCount, true)
+ textColor = labelTextColor
+ textSize = labelTextSize
+ valueFormatter = barChartModel.yValueFormatter as ValueFormatter?
+ xOffset = 10f
+ }
+ }
+ },
+ modifier = Modifier
+ .wrapContentSize()
+ .padding(4.dp),
+ update = { barChart ->
+ updateBarChartWithData(
+ chart = barChart,
+ data = barChartData,
+ colorList = barChartModel.colors,
+ colorScheme = colorScheme,
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+private fun updateBarChartWithData(
+ chart: BarChart,
+ data: List,
+ colorList: List,
+ colorScheme: ColorScheme
+) {
+ val entries = data.map { item ->
+ BarEntry(item.x, item.y.toFloatArray())
+ }
+
+ val ds = BarDataSet(entries, "").apply {
+ colors = colorList.map(Color::toArgb)
+ setDrawValues(false)
+ isHighlightEnabled = true
+ highLightColor = colorScheme.primary.toArgb()
+ highLightAlpha = 255
+ }
+ // TODO: Sets round corners for bars.
+
+ chart.data = BarData(ds)
+ chart.invalidate()
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt b/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt
new file mode 100644
index 00000000..70bc017c
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.chart
+
+import androidx.compose.ui.graphics.Color
+
+object ColorPalette {
+ // Alpha = 1
+ val red: Color = Color(0xffd93025)
+ val orange: Color = Color(0xffe8710a)
+ val yellow: Color = Color(0xfff9ab00)
+ val green: Color = Color(0xff1e8e3e)
+ val cyan: Color = Color(0xff12b5cb)
+ val blue: Color = Color(0xff1a73e8)
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt b/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt
new file mode 100644
index 00000000..9cbda21a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.chart
+
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.android.settingslib.spa.framework.theme.divider
+import com.github.mikephil.charting.charts.LineChart
+import com.github.mikephil.charting.components.XAxis
+import com.github.mikephil.charting.data.Entry
+import com.github.mikephil.charting.data.LineData
+import com.github.mikephil.charting.data.LineDataSet
+import com.github.mikephil.charting.formatter.IAxisValueFormatter
+import com.github.mikephil.charting.formatter.ValueFormatter
+
+/**
+ * The chart settings model for [LineChart].
+ */
+interface LineChartModel {
+ /**
+ * The chart data list for [LineChart].
+ */
+ val chartDataList: List
+
+ /**
+ * The label text formatter for x value.
+ */
+ val xValueFormatter: IAxisValueFormatter?
+ get() = null
+
+ /**
+ * The label text formatter for y value.
+ */
+ val yValueFormatter: IAxisValueFormatter?
+ get() = null
+
+ /**
+ * The minimum value for y-axis.
+ */
+ val yAxisMinValue: Float
+ get() = 0f
+
+ /**
+ * The maximum value for y-axis.
+ */
+ val yAxisMaxValue: Float
+ get() = 1f
+
+ /**
+ * The label count for y-axis.
+ */
+ val yAxisLabelCount: Int
+ get() = 3
+
+ /**
+ * Indicates whether to smooth the line.
+ */
+ val showSmoothLine: Boolean
+ get() = true
+}
+
+data class LineChartData(
+ var x: Float?,
+ var y: Float?,
+)
+
+@Composable
+fun LineChart(lineChartModel: LineChartModel) {
+ LineChart(
+ chartDataList = lineChartModel.chartDataList,
+ xValueFormatter = lineChartModel.xValueFormatter,
+ yValueFormatter = lineChartModel.yValueFormatter,
+ yAxisMinValue = lineChartModel.yAxisMinValue,
+ yAxisMaxValue = lineChartModel.yAxisMaxValue,
+ yAxisLabelCount = lineChartModel.yAxisLabelCount,
+ showSmoothLine = lineChartModel.showSmoothLine,
+ )
+}
+
+@Composable
+fun LineChart(
+ chartDataList: List,
+ modifier: Modifier = Modifier,
+ xValueFormatter: IAxisValueFormatter? = null,
+ yValueFormatter: IAxisValueFormatter? = null,
+ yAxisMinValue: Float = 0f,
+ yAxisMaxValue: Float = 1f,
+ yAxisLabelCount: Int = 3,
+ showSmoothLine: Boolean = true,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .height(170.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ val colorScheme = MaterialTheme.colorScheme
+ val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
+ val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
+ Crossfade(targetState = chartDataList) { lineChartData ->
+ AndroidView(factory = { context ->
+ LineChart(context).apply {
+ // Fixed Settings.
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ this.description.isEnabled = false
+ this.legend.isEnabled = false
+ this.extraBottomOffset = 4f
+ this.setTouchEnabled(false)
+
+ this.xAxis.position = XAxis.XAxisPosition.BOTTOM
+ this.xAxis.setDrawGridLines(false)
+ this.xAxis.setDrawAxisLine(false)
+ this.xAxis.textColor = labelTextColor
+ this.xAxis.textSize = labelTextSize
+ this.xAxis.yOffset = 10f
+
+ this.axisLeft.isEnabled = false
+
+ this.axisRight.setDrawAxisLine(false)
+ this.axisRight.textSize = labelTextSize
+ this.axisRight.textColor = labelTextColor
+ this.axisRight.gridColor = colorScheme.divider.toArgb()
+ this.axisRight.xOffset = 10f
+ this.axisRight.isGranularityEnabled = true
+
+ // Customizable Settings.
+ this.xAxis.valueFormatter = xValueFormatter as ValueFormatter?
+ this.axisRight.valueFormatter = yValueFormatter as ValueFormatter?
+
+ this.axisLeft.axisMinimum =
+ yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue)
+ this.axisRight.axisMinimum =
+ yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue)
+ this.axisRight.granularity =
+ (yAxisMaxValue - yAxisMinValue) / (yAxisLabelCount - 1)
+ }
+ },
+ modifier = Modifier
+ .wrapContentSize()
+ .padding(4.dp),
+ update = {
+ updateLineChartWithData(it, lineChartData, colorScheme, showSmoothLine)
+ })
+ }
+ }
+ }
+}
+
+fun updateLineChartWithData(
+ chart: LineChart,
+ data: List,
+ colorScheme: ColorScheme,
+ showSmoothLine: Boolean
+) {
+ val entries = ArrayList()
+ for (i in data.indices) {
+ val item = data[i]
+ entries.add(Entry(item.x ?: 0.toFloat(), item.y ?: 0.toFloat()))
+ }
+
+ val ds = LineDataSet(entries, "")
+ ds.colors = arrayListOf(colorScheme.primary.toArgb())
+ ds.lineWidth = 2f
+ if (showSmoothLine) {
+ ds.mode = LineDataSet.Mode.CUBIC_BEZIER
+ }
+ ds.setDrawValues(false)
+ ds.setDrawCircles(false)
+ ds.setDrawFilled(true)
+ ds.fillColor = colorScheme.primary.toArgb()
+ ds.fillAlpha = 38
+ // TODO: enable gradient fill color for line chart.
+
+ val d = LineData(ds)
+ chart.data = d
+ chart.invalidate()
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt b/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt
new file mode 100644
index 00000000..51a8d0d5
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.chart
+
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.github.mikephil.charting.charts.PieChart
+import com.github.mikephil.charting.data.PieData
+import com.github.mikephil.charting.data.PieDataSet
+import com.github.mikephil.charting.data.PieEntry
+
+/**
+ * The chart settings model for [PieChart].
+ */
+interface PieChartModel {
+ /**
+ * The chart data list for [PieChart].
+ */
+ val chartDataList: List
+
+ /**
+ * The center text in the hole of [PieChart].
+ */
+ val centerText: String?
+ get() = null
+}
+
+val colorPalette = arrayListOf(
+ ColorPalette.blue.toArgb(),
+ ColorPalette.red.toArgb(),
+ ColorPalette.yellow.toArgb(),
+ ColorPalette.green.toArgb(),
+ ColorPalette.orange.toArgb(),
+ ColorPalette.cyan.toArgb(),
+ Color.Blue.toArgb()
+)
+
+data class PieChartData(
+ var value: Float?,
+ var label: String?,
+)
+
+@Composable
+fun PieChart(pieChartModel: PieChartModel) {
+ PieChart(
+ chartDataList = pieChartModel.chartDataList,
+ centerText = pieChartModel.centerText,
+ )
+}
+
+@Composable
+fun PieChart(
+ chartDataList: List,
+ modifier: Modifier = Modifier,
+ centerText: String? = null,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .height(280.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ val colorScheme = MaterialTheme.colorScheme
+ val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
+ val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
+ val centerTextSize = MaterialTheme.typography.titleLarge.fontSize.value
+ Crossfade(targetState = chartDataList) { pieChartData ->
+ AndroidView(factory = { context ->
+ PieChart(context).apply {
+ // Fixed settings.`
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ this.isRotationEnabled = false
+ this.description.isEnabled = false
+ this.legend.isEnabled = false
+ this.setTouchEnabled(false)
+
+ this.isDrawHoleEnabled = true
+ this.holeRadius = 90.0f
+ this.setHoleColor(Color.Transparent.toArgb())
+ this.setEntryLabelColor(labelTextColor)
+ this.setEntryLabelTextSize(labelTextSize)
+ this.setCenterTextSize(centerTextSize)
+ this.setCenterTextColor(colorScheme.onSurface.toArgb())
+
+ // Customizable settings.
+ this.centerText = centerText
+ }
+ },
+ modifier = Modifier
+ .wrapContentSize()
+ .padding(4.dp), update = {
+ updatePieChartWithData(it, pieChartData)
+ })
+ }
+ }
+ }
+}
+
+fun updatePieChartWithData(
+ chart: PieChart,
+ data: List,
+) {
+ val entries = ArrayList()
+ for (i in data.indices) {
+ val item = data[i]
+ entries.add(PieEntry(item.value ?: 0.toFloat(), item.label ?: ""))
+ }
+
+ val ds = PieDataSet(entries, "")
+ ds.setDrawValues(false)
+ ds.colors = colorPalette
+ ds.sliceSpace = 2f
+ ds.yValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE
+ ds.xValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE
+ ds.valueLineColor = Color.Transparent.toArgb()
+ ds.valueLinePart1Length = 0.1f
+ ds.valueLinePart2Length = 0f
+
+ val d = PieData(ds)
+ chart.data = d
+ chart.invalidate()
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/chart/XAxisRendererProvider.kt b/spa/src/com/android/settingslib/spa/widget/chart/XAxisRendererProvider.kt
new file mode 100644
index 00000000..6569d25d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/chart/XAxisRendererProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.chart
+
+import com.github.mikephil.charting.components.XAxis
+import com.github.mikephil.charting.renderer.XAxisRenderer
+import com.github.mikephil.charting.utils.Transformer
+import com.github.mikephil.charting.utils.ViewPortHandler
+
+/** A provider for [XAxisRenderer] objects. */
+fun interface XAxisRendererProvider {
+
+ /** Provides an object of [XAxisRenderer] type. */
+ fun provideXAxisRenderer(
+ viewPortHandler: ViewPortHandler,
+ xAxis: XAxis,
+ transformer: Transformer
+ ): XAxisRenderer
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialog.kt b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialog.kt
new file mode 100644
index 00000000..de080e3d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialog.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.dialog
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+
+data class AlertDialogButton(
+ val text: String,
+ val onClick: () -> Unit = {},
+)
+
+interface AlertDialogPresenter {
+ /** Opens the dialog. */
+ fun open()
+
+ /** Closes the dialog. */
+ fun close()
+}
+
+@Composable
+fun rememberAlertDialogPresenter(
+ confirmButton: AlertDialogButton? = null,
+ dismissButton: AlertDialogButton? = null,
+ title: String? = null,
+ text: @Composable (() -> Unit)? = null,
+): AlertDialogPresenter {
+ var openDialog by rememberSaveable { mutableStateOf(false) }
+ val alertDialogPresenter = remember {
+ object : AlertDialogPresenter {
+ override fun open() {
+ openDialog = true
+ }
+
+ override fun close() {
+ openDialog = false
+ }
+ }
+ }
+ if (openDialog) {
+ alertDialogPresenter.SettingsAlertDialog(confirmButton, dismissButton, title, text)
+ }
+ return alertDialogPresenter
+}
+
+@Composable
+private fun AlertDialogPresenter.SettingsAlertDialog(
+ confirmButton: AlertDialogButton?,
+ dismissButton: AlertDialogButton?,
+ title: String?,
+ text: @Composable (() -> Unit)?,
+) {
+ AlertDialog(
+ onDismissRequest = ::close,
+ modifier = Modifier.width(getDialogWidth()),
+ confirmButton = { confirmButton?.let { Button(it) } },
+ dismissButton = dismissButton?.let { { Button(it) } },
+ title = title?.let { { Text(it) } },
+ text = text?.let {
+ {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ text()
+ }
+ }
+ },
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ )
+}
+
+@Composable
+fun getDialogWidth(): Dp {
+ val configuration = LocalConfiguration.current
+ return configuration.screenWidthDp.dp * when (configuration.orientation) {
+ Configuration.ORIENTATION_LANDSCAPE -> 0.65f
+ else -> 0.85f
+ }
+}
+
+@Composable
+private fun AlertDialogPresenter.Button(button: AlertDialogButton) {
+ TextButton(
+ onClick = {
+ close()
+ button.onClick()
+ },
+ ) {
+ Text(button.text)
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialogWithIcon.kt b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialogWithIcon.kt
new file mode 100644
index 00000000..1695e4f3
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsAlertDialogWithIcon.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.settingslib.spa.widget.dialog
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.WarningAmber
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.window.DialogProperties
+
+@Composable
+fun SettingsAlertDialogWithIcon(
+ onDismissRequest: () -> Unit,
+ confirmButton: AlertDialogButton?,
+ dismissButton: AlertDialogButton?,
+ title: String?,
+ text: @Composable (() -> Unit)?,
+) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ icon = { Icon(Icons.Default.WarningAmber, contentDescription = null) },
+ modifier = Modifier.width(getDialogWidth()),
+ confirmButton = {
+ confirmButton?.let {
+ Button(
+ onClick = {
+ it.onClick()
+ },
+ ) {
+ Text(it.text)
+ }
+ }
+ },
+ dismissButton = dismissButton?.let {
+ {
+ OutlinedButton(
+ onClick = {
+ it.onClick()
+ },
+ ) {
+ Text(it.text)
+ }
+ }
+ },
+ title = title?.let {
+ {
+ Text(
+ it,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ }
+ },
+ text = text?.let {
+ {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ text()
+ }
+ }
+ },
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ )
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/widget/dialog/SettingsDialog.kt b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsDialog.kt
new file mode 100644
index 00000000..f08e7400
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/dialog/SettingsDialog.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.dialog
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Dialog
+import androidx.navigation.compose.NavHost
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsShape
+import com.android.settingslib.spa.widget.ui.SettingsTitle
+
+@Composable
+fun SettingsDialog(
+ title: String,
+ onDismissRequest: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ SettingsDialogCard(title, content)
+ }
+}
+
+/**
+ * Card for dialog, suitable for independent dialog in the [NavHost].
+ */
+@Composable
+fun SettingsDialogCard(
+ title: String,
+ content: @Composable () -> Unit,
+) {
+ Card(
+ shape = SettingsShape.CornerExtraLarge,
+ colors = CardDefaults.cardColors(containerColor = AlertDialogDefaults.containerColor),
+ ) {
+ Column(modifier = Modifier.padding(vertical = SettingsDimension.itemPaddingAround)) {
+ Box(modifier = Modifier.padding(SettingsDimension.dialogItemPadding)) {
+ SettingsTitle(title = title, useMediumWeight = true)
+ }
+ content()
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/editor/DropdownTextBox.kt b/spa/src/com/android/settingslib/spa/widget/editor/DropdownTextBox.kt
new file mode 100644
index 00000000..679c562a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/editor/DropdownTextBox.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.settingslib.spa.widget.editor
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+
+internal interface DropdownTextBoxScope {
+ fun dismiss()
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun DropdownTextBox(
+ label: String,
+ text: String,
+ enabled: Boolean = true,
+ errorMessage: String? = null,
+ content: @Composable DropdownTextBoxScope.() -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val scope = remember {
+ object : DropdownTextBoxScope {
+ override fun dismiss() {
+ expanded = false
+ }
+ }
+ }
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = enabled && it },
+ modifier = Modifier
+ .padding(SettingsDimension.menuFieldPadding)
+ .width(Width),
+ ) {
+ OutlinedTextField(
+ // The `menuAnchor` modifier must be passed to the text field for correctness.
+ modifier = Modifier
+ .menuAnchor()
+ .fillMaxWidth(),
+ value = text,
+ onValueChange = { },
+ label = { Text(text = label) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ singleLine = true,
+ readOnly = true,
+ enabled = enabled,
+ isError = errorMessage != null,
+ supportingText = errorMessage?.let { { Text(text = it) } },
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ modifier = Modifier.width(Width),
+ onDismissRequest = { expanded = false },
+ ) { scope.content() }
+ }
+}
+
+private val Width = 310.dp
diff --git a/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownBox.kt b/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownBox.kt
new file mode 100644
index 00000000..ff141c2b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownBox.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.editor
+
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun SettingsDropdownBox(
+ label: String,
+ options: List,
+ selectedOptionIndex: Int,
+ enabled: Boolean = true,
+ onSelectedOptionChange: (Int) -> Unit,
+) {
+ DropdownTextBox(
+ label = label,
+ text = options.getOrElse(selectedOptionIndex) { "" },
+ enabled = enabled && options.isNotEmpty(),
+ ) {
+ options.forEachIndexed { index, option ->
+ DropdownMenuItem(
+ text = { Text(option) },
+ onClick = {
+ dismiss()
+ onSelectedOptionChange(index)
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun SettingsDropdownBoxPreview() {
+ val item1 = "item1"
+ val item2 = "item2"
+ val item3 = "item3"
+ val options = listOf(item1, item2, item3)
+ SettingsTheme {
+ SettingsDropdownBox(
+ label = "ExposedDropdownMenuBoxLabel",
+ options = options,
+ selectedOptionIndex = 0,
+ enabled = true,
+ ) {}
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownCheckBox.kt b/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownCheckBox.kt
new file mode 100644
index 00000000..0e7e4996
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/editor/SettingsDropdownCheckBox.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsOpacity.alphaForEnabled
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.editor.SettingsDropdownCheckOption.Companion.changeable
+
+data class SettingsDropdownCheckOption(
+ /** The displayed text of this option. */
+ val text: String,
+
+ /** If true, check / uncheck this item will check / uncheck all enabled options. */
+ val isSelectAll: Boolean = false,
+
+ /** If not changeable, cannot check or uncheck this option. */
+ val changeable: Boolean = true,
+
+ /** The selected state of this option. */
+ val selected: MutableState = mutableStateOf(false),
+
+ /** Get called when the option is clicked, no matter if it's changeable. */
+ val onClick: () -> Unit = {},
+) {
+ companion object {
+ val List.changeable: Boolean
+ get() = filter { !it.isSelectAll }.any { it.changeable }
+ }
+}
+
+@Composable
+fun SettingsDropdownCheckBox(
+ label: String,
+ options: List,
+ emptyText: String = "",
+ enabled: Boolean = true,
+ errorMessage: String? = null,
+ onSelectedStateChange: () -> Unit = {},
+) {
+ DropdownTextBox(
+ label = label,
+ text = getDisplayText(options) ?: emptyText,
+ enabled = enabled && options.changeable,
+ errorMessage = errorMessage,
+ ) {
+ for (option in options) {
+ CheckboxItem(option) {
+ option.onClick()
+ if (option.changeable) {
+ checkboxItemOnClick(options, option)
+ onSelectedStateChange()
+ }
+ }
+ }
+ }
+}
+
+private fun getDisplayText(options: List): String? {
+ val selectedOptions = options.filter { it.selected.value }
+ if (selectedOptions.isEmpty()) return null
+ return selectedOptions.filter { it.isSelectAll }.ifEmpty { selectedOptions }
+ .joinToString { it.text }
+}
+
+private fun checkboxItemOnClick(
+ options: List,
+ clickedOption: SettingsDropdownCheckOption,
+) {
+ if (!clickedOption.changeable) return
+ val newChecked = !clickedOption.selected.value
+ if (clickedOption.isSelectAll) {
+ for (option in options.filter { it.changeable }) option.selected.value = newChecked
+ } else {
+ clickedOption.selected.value = newChecked
+ }
+ val (selectAllOptions, regularOptions) = options.partition { it.isSelectAll }
+ val isAllRegularOptionsChecked = regularOptions.all { it.selected.value }
+ selectAllOptions.forEach { it.selected.value = isAllRegularOptionsChecked }
+}
+
+@Composable
+private fun CheckboxItem(
+ option: SettingsDropdownCheckOption,
+ onClick: (SettingsDropdownCheckOption) -> Unit,
+) {
+ TextButton(
+ onClick = { onClick(option) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingAround),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = option.selected.value,
+ onCheckedChange = null,
+ enabled = option.changeable,
+ )
+ Text(text = option.text, modifier = Modifier.alphaForEnabled(option.changeable))
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ActionButtonsPreview() {
+ val item1 = SettingsDropdownCheckOption("item1")
+ val item2 = SettingsDropdownCheckOption("item2")
+ val item3 = SettingsDropdownCheckOption("item3")
+ val options = listOf(item1, item2, item3)
+ SettingsTheme {
+ SettingsDropdownCheckBox(
+ label = "label",
+ options = options,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/editor/SettingsOutlinedTextField.kt b/spa/src/com/android/settingslib/spa/widget/editor/SettingsOutlinedTextField.kt
new file mode 100644
index 00000000..bdc6a689
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/editor/SettingsOutlinedTextField.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.editor
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+fun SettingsOutlinedTextField(
+ value: String,
+ label: String,
+ errorMessage: String? = null,
+ singleLine: Boolean = true,
+ enabled: Boolean = true,
+ shape: Shape = OutlinedTextFieldDefaults.shape,
+ onTextChange: (String) -> Unit
+) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(SettingsDimension.textFieldPadding),
+ value = value,
+ onValueChange = onTextChange,
+ label = {
+ Text(text = label)
+ },
+ singleLine = singleLine,
+ enabled = enabled,
+ isError = errorMessage != null,
+ supportingText = {
+ if (errorMessage != null) {
+ Text(text = errorMessage)
+ }
+ },
+ shape = shape
+ )
+}
+
+@Preview
+@Composable
+private fun SettingsOutlinedTextFieldPreview() {
+ var value by remember { mutableStateOf("Enabled Value") }
+ SettingsTheme {
+ SettingsOutlinedTextField(
+ value = value,
+ label = "OutlinedTextField Enabled",
+ enabled = true,
+ onTextChange = {value = it})
+ }
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/widget/editor/SettingsTextFieldPassword.kt b/spa/src/com/android/settingslib/spa/widget/editor/SettingsTextFieldPassword.kt
new file mode 100644
index 00000000..3102a00a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/editor/SettingsTextFieldPassword.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.editor
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Visibility
+import androidx.compose.material.icons.outlined.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+fun SettingsTextFieldPassword(
+ value: String,
+ label: String,
+ enabled: Boolean = true,
+ onTextChange: (String) -> Unit,
+) {
+ var visibility by remember { mutableStateOf(false) }
+ OutlinedTextField(
+ modifier = Modifier
+ .padding(SettingsDimension.menuFieldPadding)
+ .fillMaxWidth(),
+ value = value,
+ onValueChange = onTextChange,
+ label = { Text(text = label) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Send
+ ),
+ enabled = enabled,
+ trailingIcon = {
+ Icon(
+ imageVector = if (visibility) Icons.Outlined.VisibilityOff
+ else Icons.Outlined.Visibility,
+ contentDescription = "Visibility Icon",
+ modifier = Modifier
+ .testTag("Visibility Icon")
+ .size(SettingsDimension.itemIconSize)
+ .toggleable(visibility) {
+ visibility = !visibility
+ },
+ )
+ },
+ visualTransformation = if (visibility) VisualTransformation.None
+ else PasswordVisualTransformation()
+ )
+}
+
+@Preview
+@Composable
+private fun SettingsTextFieldPasswordPreview() {
+ var value by remember { mutableStateOf("value") }
+ SettingsTheme {
+ SettingsTextFieldPassword(
+ value = value,
+ label = "label",
+ onTextChange = { value = it },
+ )
+ }
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/widget/illustration/Illustration.kt b/spa/src/com/android/settingslib/spa/widget/illustration/Illustration.kt
new file mode 100644
index 00000000..6a2163c0
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/illustration/Illustration.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.illustration
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.ui.ImageBox
+import com.android.settingslib.spa.widget.ui.Lottie
+enum class ResourceType { IMAGE, LOTTIE }
+
+/**
+ * The widget model for [Illustration] widget.
+ */
+interface IllustrationModel {
+ /**
+ * The resource id of this [Illustration].
+ */
+ val resId: Int
+
+ /**
+ * The resource type of the [Illustration].
+ *
+ * It should be Lottie or Image.
+ */
+ val resourceType: ResourceType
+}
+
+/**
+ * Illustration widget.
+ *
+ * Data is provided through [IllustrationModel].
+ */
+@Composable
+fun Illustration(model: IllustrationModel) {
+ Illustration(
+ resId = model.resId,
+ resourceType = model.resourceType,
+ modifier = Modifier,
+ )
+}
+
+@Composable
+fun Illustration(
+ resId: Int,
+ resourceType: ResourceType,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = SettingsDimension.illustrationPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ val illustrationModifier = modifier
+ .sizeIn(
+ maxWidth = SettingsDimension.illustrationMaxWidth,
+ maxHeight = SettingsDimension.illustrationMaxHeight,
+ )
+ .clip(RoundedCornerShape(SettingsDimension.illustrationCornerRadius))
+ .background(color = Color.Transparent)
+
+ when (resourceType) {
+ ResourceType.LOTTIE -> {
+ Lottie(
+ resId = resId,
+ modifier = illustrationModifier,
+ )
+ }
+ ResourceType.IMAGE -> {
+ ImageBox(
+ resId = resId,
+ contentDescription = null,
+ modifier = illustrationModifier,
+ )
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/BaseLayout.kt b/spa/src/com/android/settingslib/spa/widget/preference/BaseLayout.kt
new file mode 100644
index 00000000..56d75d8b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/BaseLayout.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsOpacity.alphaForEnabled
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.ui.SettingsTitle
+
+@Composable
+internal fun BaseLayout(
+ title: String,
+ subTitle: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ icon: (@Composable () -> Unit)? = null,
+ enabled: () -> Boolean = { true },
+ paddingStart: Dp = SettingsDimension.itemPaddingStart,
+ paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
+ paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
+ widget: @Composable () -> Unit = {},
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(end = paddingEnd),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val alphaModifier = Modifier.alphaForEnabled(enabled())
+ BaseIcon(icon, alphaModifier, paddingStart)
+ Titles(
+ title = title,
+ subTitle = subTitle,
+ modifier = alphaModifier
+ .weight(1f)
+ .padding(vertical = paddingVertical),
+ )
+ widget()
+ }
+}
+
+@Composable
+internal fun BaseIcon(
+ icon: @Composable (() -> Unit)?,
+ modifier: Modifier,
+ paddingStart: Dp,
+) {
+ if (icon != null) {
+ Box(
+ modifier = modifier.size(SettingsDimension.itemIconContainerSize),
+ contentAlignment = Alignment.Center,
+ ) {
+ icon()
+ }
+ } else {
+ Spacer(modifier = Modifier.width(width = paddingStart))
+ }
+}
+
+// Extracts a scope to avoid frequent recompose outside scope.
+@Composable
+private fun Titles(title: String, subTitle: @Composable () -> Unit, modifier: Modifier) {
+ Column(modifier) {
+ SettingsTitle(title)
+ subTitle()
+ }
+}
+
+@Preview
+@Composable
+private fun BaseLayoutPreview() {
+ SettingsTheme {
+ BaseLayout(
+ title = "Title",
+ subTitle = {
+ HorizontalDivider(thickness = 10.dp)
+ }
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/BasePreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/BasePreference.kt
new file mode 100644
index 00000000..194ed81d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/BasePreference.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BatteryChargingFull
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.ui.SettingsBody
+
+@Composable
+internal fun BasePreference(
+ title: String,
+ summary: () -> String,
+ modifier: Modifier = Modifier,
+ singleLineSummary: Boolean = false,
+ icon: @Composable (() -> Unit)? = null,
+ enabled: () -> Boolean = { true },
+ paddingStart: Dp = SettingsDimension.itemPaddingStart,
+ paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
+ paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
+ widget: @Composable () -> Unit = {},
+) {
+ BaseLayout(
+ title = title,
+ subTitle = {
+ SettingsBody(
+ body = summary(),
+ maxLines = if (singleLineSummary) 1 else Int.MAX_VALUE,
+ )
+ },
+ modifier = modifier,
+ icon = icon,
+ enabled = enabled,
+ paddingStart = paddingStart,
+ paddingEnd = paddingEnd,
+ paddingVertical = paddingVertical,
+ widget = widget,
+ )
+}
+
+@Preview
+@Composable
+private fun BasePreferencePreview() {
+ SettingsTheme {
+ BasePreference(
+ title = "Screen Saver",
+ summary = { "Clock" },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun BasePreferenceIconPreview() {
+ SettingsTheme {
+ BasePreference(
+ title = "Screen Saver",
+ summary = { "Clock" },
+ icon = {
+ Icon(imageVector = Icons.Outlined.BatteryChargingFull, contentDescription = null)
+ },
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/CheckboxPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/CheckboxPreference.kt
new file mode 100644
index 00000000..93d77d40
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/CheckboxPreference.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AirplanemodeActive
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.EntryHighlight
+import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
+import com.android.settingslib.spa.widget.ui.SettingsCheckbox
+import com.android.settingslib.spa.widget.ui.SettingsIcon
+
+/**
+ * The widget model for [CheckboxPreference] widget.
+ */
+interface CheckboxPreferenceModel {
+ /**
+ * The title of this [CheckboxPreference].
+ */
+ val title: String
+
+ /**
+ * The summary of this [CheckboxPreference].
+ */
+ val summary: () -> String
+ get() = { "" }
+
+ /**
+ * The icon of this [Preference].
+ *
+ * Default is `null` which means no icon.
+ */
+ val icon: (@Composable () -> Unit)?
+ get() = null
+
+ /**
+ * Indicates whether this [CheckboxPreference] is checked.
+ *
+ * This can be `null` during the data loading before the data is available.
+ */
+ val checked: () -> Boolean?
+
+ /**
+ * Indicates whether this [CheckboxPreference] is changeable.
+ *
+ * Not changeable [CheckboxPreference] will be displayed in disabled style.
+ */
+ val changeable: () -> Boolean
+ get() = { true }
+
+ /**
+ * The checkbox change handler of this [CheckboxPreference].
+ *
+ * If `null`, this [CheckboxPreference] is not [toggleable].
+ */
+ val onCheckedChange: ((newChecked: Boolean) -> Unit)?
+}
+
+/**
+ * CheckboxPreference widget.
+ *
+ * Data is provided through [CheckboxPreferenceModel].
+ */
+@Composable
+fun CheckboxPreference(model: CheckboxPreferenceModel) {
+ EntryHighlight {
+ InternalCheckboxPreference(
+ title = model.title,
+ summary = model.summary,
+ icon = model.icon,
+ checked = model.checked(),
+ changeable = model.changeable(),
+ onCheckedChange = model.onCheckedChange,
+ )
+ }
+}
+
+@Composable
+internal fun InternalCheckboxPreference(
+ title: String,
+ summary: () -> String = { "" },
+ icon: @Composable (() -> Unit)? = null,
+ checked: Boolean?,
+ changeable: Boolean = true,
+ paddingStart: Dp = SettingsDimension.itemPaddingStart,
+ paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
+ paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
+ onCheckedChange: ((newChecked: Boolean) -> Unit)?,
+) {
+ val indication = LocalIndication.current
+ val onChangeWithLog = wrapOnSwitchWithLog(onCheckedChange)
+ val interactionSource = remember { MutableInteractionSource() }
+ val modifier = remember(checked, changeable) {
+ if (checked != null && onChangeWithLog != null) {
+ Modifier.toggleable(
+ value = checked,
+ interactionSource = interactionSource,
+ indication = indication,
+ enabled = changeable,
+ role = Role.Checkbox,
+ onValueChange = onChangeWithLog,
+ )
+ } else Modifier
+ }
+ BasePreference(
+ title = title,
+ summary = summary,
+ modifier = modifier,
+ enabled = { changeable },
+ paddingStart = paddingStart,
+ paddingEnd = paddingEnd,
+ paddingVertical = paddingVertical,
+ icon = icon,
+ ) {
+ Spacer(Modifier.width(SettingsDimension.itemPaddingEnd))
+ SettingsCheckbox(
+ checked = checked,
+ changeable = { changeable },
+ // The onCheckedChange is handled on the whole CheckboxPreference.
+ // DO NOT set it on SettingsCheckbox.
+ onCheckedChange = null,
+ interactionSource = interactionSource,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CheckboxPreferencePreview() {
+ SettingsTheme {
+ Column {
+ InternalCheckboxPreference(
+ title = "Use Dark theme",
+ checked = true,
+ onCheckedChange = {},
+ )
+ InternalCheckboxPreference(
+ title = "Use Dark theme",
+ summary = { "Summary" },
+ checked = false,
+ onCheckedChange = {},
+ )
+ InternalCheckboxPreference(
+ title = "Use Dark theme",
+ summary = { "Summary" },
+ checked = true,
+ onCheckedChange = {},
+ icon = @Composable {
+ SettingsIcon(imageVector = Icons.Outlined.AirplanemodeActive)
+ },
+ )
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/ListPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/ListPreference.kt
new file mode 100644
index 00000000..1a04bb83
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/ListPreference.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material3.RadioButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.IntState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.dialog.SettingsDialog
+import com.android.settingslib.spa.widget.ui.SettingsBody
+import com.android.settingslib.spa.widget.ui.SettingsDialogItem
+
+data class ListPreferenceOption(
+ val id: Int,
+ val text: String,
+ val summary: String = String()
+)
+
+/**
+ * The widget model for [ListPreference] widget.
+ */
+interface ListPreferenceModel {
+ /**
+ * The title of this [ListPreference].
+ */
+ val title: String
+
+ /**
+ * The icon of this [ListPreference].
+ *
+ * Default is `null` which means no icon.
+ */
+ val icon: (@Composable () -> Unit)?
+ get() = null
+
+ /**
+ * Indicates whether this [ListPreference] is enabled.
+ *
+ * Disabled [ListPreference] will be displayed in disabled style.
+ */
+ val enabled: () -> Boolean
+ get() = { true }
+
+ val options: List
+
+ val selectedId: IntState
+
+ val onIdSelected: (id: Int) -> Unit
+}
+
+@Composable
+fun ListPreference(model: ListPreferenceModel) {
+ var dialogOpened by rememberSaveable { mutableStateOf(false) }
+ if (dialogOpened) {
+ SettingsDialog(
+ title = model.title,
+ onDismissRequest = { dialogOpened = false },
+ ) {
+ Column(modifier = Modifier.selectableGroup()) {
+ for (option in model.options) {
+ Radio(option, model.selectedId.intValue, model.enabled()) {
+ dialogOpened = false
+ model.onIdSelected(it)
+ }
+ }
+ }
+ }
+ }
+ Preference(model = remember(model) {
+ object : PreferenceModel {
+ override val title = model.title
+ override val summary = {
+ model.options.find { it.id == model.selectedId.intValue }?.text ?: ""
+ }
+ override val icon = model.icon
+ override val enabled = model.enabled
+ override val onClick = { dialogOpened = true }.takeIf { model.options.isNotEmpty() }
+ }
+ })
+}
+
+@Composable
+private fun Radio(
+ option: ListPreferenceOption,
+ selectedId: Int,
+ enabled: Boolean,
+ onIdSelected: (id: Int) -> Unit,
+) {
+ val selected = option.id == selectedId
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = selected,
+ enabled = enabled,
+ onClick = { onIdSelected(option.id) },
+ role = Role.RadioButton,
+ )
+ .padding(SettingsDimension.dialogItemPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = null, enabled = enabled)
+ Spacer(modifier = Modifier.width(SettingsDimension.itemPaddingEnd))
+ Column {
+ SettingsDialogItem(text = option.text, enabled = enabled)
+ if (option.summary != String()) {
+ SettingsBody(
+ body = option.summary,
+ maxLines = 1
+ )
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/MainSwitchPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/MainSwitchPreference.kt
new file mode 100644
index 00000000..fc8de803
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/MainSwitchPreference.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsShape
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.EntryHighlight
+
+@Composable
+fun MainSwitchPreference(model: SwitchPreferenceModel) {
+ EntryHighlight {
+ Surface(
+ modifier = Modifier.padding(SettingsDimension.itemPaddingEnd),
+ color = when (model.checked()) {
+ true -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.secondaryContainer
+ },
+ shape = SettingsShape.CornerExtraLarge,
+ ) {
+ InternalSwitchPreference(
+ title = model.title,
+ checked = model.checked(),
+ changeable = model.changeable(),
+ onCheckedChange = model.onCheckedChange,
+ paddingStart = 20.dp,
+ paddingEnd = 20.dp,
+ paddingVertical = 18.dp,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun MainSwitchPreferencePreview() {
+ SettingsTheme {
+ Column {
+ MainSwitchPreference(object : SwitchPreferenceModel {
+ override val title = "Use Dark theme"
+ override val checked = { true }
+ override val onCheckedChange: (Boolean) -> Unit = {}
+ })
+ MainSwitchPreference(object : SwitchPreferenceModel {
+ override val title = "Use Dark theme"
+ override val checked = { false }
+ override val onCheckedChange: (Boolean) -> Unit = {}
+ })
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/Preference.kt b/spa/src/com/android/settingslib/spa/widget/preference/Preference.kt
new file mode 100644
index 00000000..3acf075d
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/Preference.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.clickable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.framework.common.EntryMacro
+import com.android.settingslib.spa.framework.common.EntrySearchData
+import com.android.settingslib.spa.framework.compose.navigator
+import com.android.settingslib.spa.framework.util.EntryHighlight
+import com.android.settingslib.spa.framework.util.wrapOnClickWithLog
+import com.android.settingslib.spa.widget.ui.createSettingsIcon
+
+data class SimplePreferenceMacro(
+ val title: String,
+ val summary: String? = null,
+ val icon: ImageVector? = null,
+ val disabled: Boolean = false,
+ val clickRoute: String? = null,
+ val searchKeywords: List = emptyList(),
+) : EntryMacro {
+ @Composable
+ override fun UiLayout() {
+ Preference(model = object : PreferenceModel {
+ override val title: String = this@SimplePreferenceMacro.title
+ override val summary = { this@SimplePreferenceMacro.summary ?: "" }
+ override val icon = createSettingsIcon(this@SimplePreferenceMacro.icon)
+ override val enabled = { !disabled }
+ override val onClick = navigator(clickRoute)
+ })
+ }
+
+ override fun getSearchData(): EntrySearchData {
+ return EntrySearchData(
+ title = this@SimplePreferenceMacro.title,
+ keyword = searchKeywords
+ )
+ }
+}
+
+/**
+ * The widget model for [Preference] widget.
+ */
+interface PreferenceModel {
+ /**
+ * The title of this [Preference].
+ */
+ val title: String
+
+ /**
+ * The summary of this [Preference].
+ */
+ val summary: () -> String
+ get() = { "" }
+
+ /**
+ * The icon of this [Preference].
+ *
+ * Default is `null` which means no icon.
+ */
+ val icon: (@Composable () -> Unit)?
+ get() = null
+
+ /**
+ * Indicates whether this [Preference] is enabled.
+ *
+ * Disabled [Preference] will be displayed in disabled style.
+ */
+ val enabled: () -> Boolean
+ get() = { true }
+
+ /**
+ * The on click handler of this [Preference].
+ *
+ * This also indicates whether this [Preference] is clickable.
+ */
+ val onClick: (() -> Unit)?
+ get() = null
+}
+
+/**
+ * Preference widget.
+ *
+ * Data is provided through [PreferenceModel].
+ */
+@Composable
+fun Preference(
+ model: PreferenceModel,
+ singleLineSummary: Boolean = false,
+) {
+ val onClickWithLog = wrapOnClickWithLog(model.onClick)
+ val enabled = model.enabled()
+ val modifier = if (onClickWithLog != null) {
+ Modifier.clickable(enabled = enabled, onClick = onClickWithLog)
+ } else Modifier
+ EntryHighlight {
+ BasePreference(
+ title = model.title,
+ summary = model.summary,
+ singleLineSummary = singleLineSummary,
+ modifier = modifier,
+ icon = model.icon,
+ enabled = model.enabled,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/ProgressBarPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/ProgressBarPreference.kt
new file mode 100644
index 00000000..7f7088a0
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/ProgressBarPreference.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.ui.LinearProgressBar
+import com.android.settingslib.spa.widget.ui.SettingsTitle
+
+/**
+ * The widget model for [ProgressBarPreference] widget.
+ */
+interface ProgressBarPreferenceModel {
+ /**
+ * The title of this [ProgressBarPreference].
+ */
+ val title: String
+
+ /**
+ * The progress fraction of the ProgressBar. Should be float in range [0f, 1f]
+ */
+ val progress: Float
+
+ /**
+ * The icon image for [ProgressBarPreference]. If not specified, hides the icon by default.
+ */
+ val icon: ImageVector?
+ get() = null
+
+ /**
+ * The height of the ProgressBar.
+ */
+ val height: Float
+ get() = 4f
+
+ /**
+ * Indicates whether to use rounded corner for the progress bars.
+ */
+ val roundedCorner: Boolean
+ get() = true
+}
+
+/**
+ * Progress bar preference widget.
+ *
+ * Data is provided through [ProgressBarPreferenceModel].
+ */
+@Composable
+fun ProgressBarPreference(model: ProgressBarPreferenceModel) {
+ ProgressBarPreference(
+ title = model.title,
+ progress = model.progress,
+ icon = model.icon,
+ height = model.height,
+ roundedCorner = model.roundedCorner,
+ )
+}
+
+/**
+ * Progress bar with data preference widget.
+ */
+@Composable
+fun ProgressBarWithDataPreference(model: ProgressBarPreferenceModel, data: String) {
+ val icon = model.icon
+ ProgressBarWithDataPreference(
+ title = model.title,
+ data = data,
+ progress = model.progress,
+ icon = if (icon != null) ({
+ Icon(imageVector = icon, contentDescription = null)
+ }) else null,
+ height = model.height,
+ roundedCorner = model.roundedCorner,
+ )
+}
+
+@Composable
+internal fun ProgressBarPreference(
+ title: String,
+ progress: Float,
+ icon: ImageVector? = null,
+ height: Float = 4f,
+ roundedCorner: Boolean = true,
+) {
+ BaseLayout(
+ title = title,
+ subTitle = {
+ LinearProgressBar(progress, height, roundedCorner)
+ },
+ icon = if (icon != null) ({
+ Icon(imageVector = icon, contentDescription = null)
+ }) else null,
+ )
+}
+
+
+@Composable
+internal fun ProgressBarWithDataPreference(
+ title: String,
+ data: String,
+ progress: Float,
+ icon: (@Composable () -> Unit)? = null,
+ height: Float = 4f,
+ roundedCorner: Boolean = true,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = SettingsDimension.itemPaddingEnd),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BaseIcon(icon, Modifier, SettingsDimension.itemPaddingStart)
+ TitleWithData(
+ title = title,
+ data = data,
+ subTitle = {
+ LinearProgressBar(progress, height, roundedCorner)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = SettingsDimension.itemPaddingVertical),
+ )
+ }
+}
+
+@Composable
+private fun TitleWithData(
+ title: String,
+ data: String,
+ subTitle: @Composable () -> Unit,
+ modifier: Modifier
+) {
+ Column(modifier) {
+ Row {
+ Box(modifier = Modifier.weight(1f)) {
+ SettingsTitle(title)
+ }
+ Text(
+ text = data,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ subTitle()
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/RadioPreferences.kt b/spa/src/com/android/settingslib/spa/widget/preference/RadioPreferences.kt
new file mode 100644
index 00000000..8300ce85
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/RadioPreferences.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material3.RadioButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.ui.CategoryTitle
+import com.android.settingslib.spa.widget.ui.SettingsListItem
+
+@Composable
+fun RadioPreferences(model: ListPreferenceModel) {
+ CategoryTitle(title = model.title)
+ Spacer(modifier = Modifier.width(SettingsDimension.itemDividerHeight))
+ Column(modifier = Modifier.selectableGroup()) {
+ for (option in model.options) {
+ Radio2(option, model.selectedId.intValue, model.enabled()) {
+ model.onIdSelected(it)
+ }
+ }
+ }
+}
+
+@Composable
+fun Radio2(
+ option: ListPreferenceOption,
+ selectedId: Int,
+ enabled: Boolean,
+ onIdSelected: (id: Int) -> Unit,
+) {
+ val selected = option.id == selectedId
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = selected,
+ enabled = enabled,
+ onClick = { onIdSelected(option.id) },
+ role = Role.RadioButton,
+ )
+ .padding(SettingsDimension.dialogItemPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = null, enabled = enabled)
+ Spacer(modifier = Modifier.width(SettingsDimension.itemDividerHeight))
+ SettingsListItem(text = option.text, enabled = enabled)
+ }
+}
\ No newline at end of file
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/SliderPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/SliderPreference.kt
new file mode 100644
index 00000000..7bca38fd
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/SliderPreference.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AccessAlarm
+import androidx.compose.material.icons.outlined.MusicNote
+import androidx.compose.material.icons.outlined.MusicOff
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.EntryHighlight
+import com.android.settingslib.spa.widget.ui.SettingsSlider
+
+/**
+ * The widget model for [SliderPreference] widget.
+ */
+interface SliderPreferenceModel {
+ /**
+ * The title of this [SliderPreference].
+ */
+ val title: String
+
+ /**
+ * The initial position of the [SliderPreference].
+ */
+ val initValue: Int
+
+ /**
+ * The value range for this [SliderPreference].
+ */
+ val valueRange: IntRange
+ get() = 0..100
+
+ /**
+ * The lambda to be invoked during the value change by dragging or a click. This callback is
+ * used to get the real time value of the [SliderPreference].
+ */
+ val onValueChange: ((value: Int) -> Unit)?
+ get() = null
+
+ /**
+ * The lambda to be invoked when value change has ended. This callback is used to get when the
+ * user has completed selecting a new value by ending a drag or a click.
+ */
+ val onValueChangeFinished: (() -> Unit)?
+ get() = null
+
+ /**
+ * The icon image for [SliderPreference]. If not specified, the slider hides the icon by default.
+ */
+ val icon: ImageVector?
+ get() = null
+
+ /**
+ * Indicates whether to show step marks. If show step marks, when user finish sliding,
+ * the slider will automatically jump to the nearest step mark. Otherwise, the slider hides
+ * the step marks by default.
+ *
+ * The step is fixed to 1.
+ */
+ val showSteps: Boolean
+ get() = false
+}
+
+/**
+ * Settings slider widget.
+ *
+ * Data is provided through [SliderPreferenceModel].
+ */
+@Composable
+fun SliderPreference(model: SliderPreferenceModel) {
+ EntryHighlight {
+ SliderPreference(
+ title = model.title,
+ initValue = model.initValue,
+ valueRange = model.valueRange,
+ onValueChange = model.onValueChange,
+ onValueChangeFinished = model.onValueChangeFinished,
+ icon = model.icon,
+ showSteps = model.showSteps,
+ )
+ }
+}
+
+@Composable
+internal fun SliderPreference(
+ title: String,
+ initValue: Int,
+ modifier: Modifier = Modifier,
+ valueRange: IntRange = 0..100,
+ onValueChange: ((value: Int) -> Unit)? = null,
+ onValueChangeFinished: (() -> Unit)? = null,
+ icon: ImageVector? = null,
+ showSteps: Boolean = false,
+) {
+ BaseLayout(
+ title = title,
+ subTitle = {
+ SettingsSlider(
+ initValue,
+ modifier,
+ valueRange,
+ onValueChange,
+ onValueChangeFinished,
+ showSteps
+ )
+ },
+ icon = if (icon != null) ({
+ Icon(imageVector = icon, contentDescription = null)
+ }) else null,
+ )
+}
+
+@Preview
+@Composable
+private fun SliderPreferencePreview() {
+ SettingsTheme {
+ val initValue = 30
+ var sliderPosition by rememberSaveable { mutableStateOf(initValue) }
+ SliderPreference(
+ title = "Alarm Volume",
+ initValue = 30,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ println("onValueChangeFinished: the value is $sliderPosition")
+ },
+ icon = Icons.Outlined.AccessAlarm,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SliderPreferenceIconChangePreview() {
+ SettingsTheme {
+ var icon by remember { mutableStateOf(Icons.Outlined.MusicNote) }
+ SliderPreference(
+ title = "Media Volume",
+ initValue = 40,
+ onValueChange = { it: Int ->
+ icon = if (it > 0) Icons.Outlined.MusicNote else Icons.Outlined.MusicOff
+ },
+ icon = icon,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SliderPreferenceStepsPreview() {
+ SettingsTheme {
+ SliderPreference(
+ title = "Display Text",
+ initValue = 2,
+ valueRange = 1..5,
+ showSteps = true,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/SwitchPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/SwitchPreference.kt
new file mode 100644
index 00000000..aceb5458
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/SwitchPreference.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AirplanemodeActive
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.util.EntryHighlight
+import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
+import com.android.settingslib.spa.widget.ui.SettingsIcon
+import com.android.settingslib.spa.widget.ui.SettingsSwitch
+
+/**
+ * The widget model for [SwitchPreference] widget.
+ */
+interface SwitchPreferenceModel {
+ /**
+ * The title of this [SwitchPreference].
+ */
+ val title: String
+
+ /**
+ * The summary of this [SwitchPreference].
+ */
+ val summary: () -> String
+ get() = { "" }
+
+ /**
+ * The icon of this [Preference].
+ *
+ * Default is `null` which means no icon.
+ */
+ val icon: (@Composable () -> Unit)?
+ get() = null
+
+ /**
+ * Indicates whether this [SwitchPreference] is checked.
+ *
+ * This can be `null` during the data loading before the data is available.
+ */
+ val checked: () -> Boolean?
+
+ /**
+ * Indicates whether this [SwitchPreference] is changeable.
+ *
+ * Not changeable [SwitchPreference] will be displayed in disabled style.
+ */
+ val changeable: () -> Boolean
+ get() = { true }
+
+ /**
+ * The switch change handler of this [SwitchPreference].
+ *
+ * If `null`, this [SwitchPreference] is not [toggleable].
+ */
+ val onCheckedChange: ((newChecked: Boolean) -> Unit)?
+}
+
+/**
+ * SwitchPreference widget.
+ *
+ * Data is provided through [SwitchPreferenceModel].
+ */
+@Composable
+fun SwitchPreference(model: SwitchPreferenceModel) {
+ EntryHighlight {
+ InternalSwitchPreference(
+ title = model.title,
+ summary = model.summary,
+ icon = model.icon,
+ checked = model.checked(),
+ changeable = model.changeable(),
+ onCheckedChange = model.onCheckedChange,
+ )
+ }
+}
+
+@Composable
+internal fun InternalSwitchPreference(
+ title: String,
+ summary: () -> String = { "" },
+ icon: @Composable (() -> Unit)? = null,
+ checked: Boolean?,
+ changeable: Boolean = true,
+ paddingStart: Dp = SettingsDimension.itemPaddingStart,
+ paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
+ paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
+ onCheckedChange: ((newChecked: Boolean) -> Unit)?,
+) {
+ val indication = LocalIndication.current
+ val onChangeWithLog = wrapOnSwitchWithLog(onCheckedChange)
+ val interactionSource = remember { MutableInteractionSource() }
+ val modifier = remember(checked, changeable) {
+ if (checked != null && onChangeWithLog != null) {
+ Modifier.toggleable(
+ value = checked,
+ interactionSource = interactionSource,
+ indication = indication,
+ enabled = changeable,
+ role = Role.Switch,
+ onValueChange = onChangeWithLog,
+ )
+ } else Modifier
+ }
+ BasePreference(
+ title = title,
+ summary = summary,
+ modifier = modifier,
+ enabled = { changeable },
+ paddingStart = paddingStart,
+ paddingEnd = paddingEnd,
+ paddingVertical = paddingVertical,
+ icon = icon,
+ ) {
+ Spacer(Modifier.width(SettingsDimension.itemPaddingEnd))
+ SettingsSwitch(
+ checked = checked,
+ changeable = { changeable },
+ // The onCheckedChange is handled on the whole SwitchPreference.
+ // DO NOT set it on SettingsSwitch.
+ onCheckedChange = null,
+ interactionSource = interactionSource,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SwitchPreferencePreview() {
+ SettingsTheme {
+ Column {
+ InternalSwitchPreference(
+ title = "Use Dark theme",
+ checked = true,
+ onCheckedChange = {},
+ )
+ InternalSwitchPreference(
+ title = "Use Dark theme",
+ summary = { "Summary" },
+ checked = false,
+ onCheckedChange = {},
+ )
+ InternalSwitchPreference(
+ title = "Use Dark theme",
+ summary = { "Summary" },
+ checked = true,
+ onCheckedChange = {},
+ icon = @Composable {
+ SettingsIcon(imageVector = Icons.Outlined.AirplanemodeActive)
+ },
+ )
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetButtonPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetButtonPreference.kt
new file mode 100644
index 00000000..98660239
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetButtonPreference.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.framework.util.EntryHighlight
+
+@Composable
+fun TwoTargetButtonPreference(
+ title: String,
+ summary: () -> String,
+ icon: @Composable (() -> Unit)? = null,
+ onClick: () -> Unit,
+ buttonIcon: ImageVector,
+ buttonIconDescription: String,
+ onButtonClick: () -> Unit
+) {
+ EntryHighlight {
+ TwoTargetPreference(
+ title = title,
+ summary = summary,
+ onClick = onClick,
+ icon = icon,
+ ) {
+ IconButton(onClick = onButtonClick) {
+ Icon(imageVector = buttonIcon, contentDescription = buttonIconDescription)
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetPreference.kt
new file mode 100644
index 00000000..3216e37b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetPreference.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.preference
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.divider
+
+@Composable
+internal fun TwoTargetPreference(
+ title: String,
+ summary: () -> String,
+ onClick: () -> Unit,
+ icon: @Composable (() -> Unit)? = null,
+ widget: @Composable () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = SettingsDimension.itemPaddingEnd),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ Preference(
+ object : PreferenceModel {
+ override val title = title
+ override val summary = summary
+ override val icon = icon
+ override val onClick = onClick
+ }
+ )
+ }
+ PreferenceDivider()
+ widget()
+ }
+}
+
+@Composable
+private fun PreferenceDivider() {
+ Box(
+ Modifier
+ .padding(horizontal = SettingsDimension.itemPaddingEnd)
+ .size(width = 1.dp, height = SettingsDimension.itemDividerHeight)
+ .background(color = MaterialTheme.colorScheme.divider)
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetSwitchPreference.kt b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetSwitchPreference.kt
new file mode 100644
index 00000000..7eed7458
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/preference/TwoTargetSwitchPreference.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.preference
+
+import androidx.compose.runtime.Composable
+import com.android.settingslib.spa.framework.util.EntryHighlight
+import com.android.settingslib.spa.widget.ui.SettingsSwitch
+
+@Composable
+fun TwoTargetSwitchPreference(
+ model: SwitchPreferenceModel,
+ icon: @Composable (() -> Unit)? = null,
+ onClick: () -> Unit,
+) {
+ EntryHighlight {
+ TwoTargetPreference(
+ title = model.title,
+ summary = model.summary,
+ onClick = onClick,
+ icon = icon,
+ ) {
+ SettingsSwitch(
+ checked = model.checked(),
+ changeable = model.changeable,
+ onCheckedChange = model.onCheckedChange,
+ )
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/Actions.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/Actions.kt
new file mode 100644
index 00000000..5f320f7a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/Actions.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.appcompat.R
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material.icons.outlined.FindInPage
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.android.settingslib.spa.framework.compose.LocalNavController
+
+/** Action that navigates back to last page. */
+@Composable
+internal fun NavigateBack() {
+ val navController = LocalNavController.current
+ val contentDescription = stringResource(R.string.abc_action_bar_up_description)
+ BackAction(contentDescription) {
+ navController.navigateBack()
+ }
+}
+
+/** Action that collapses the search bar. */
+@Composable
+internal fun CollapseAction(onClick: () -> Unit) {
+ val contentDescription = stringResource(R.string.abc_toolbar_collapse_description)
+ BackAction(contentDescription, onClick)
+}
+
+@Composable
+private fun BackAction(contentDescription: String, onClick: () -> Unit) {
+ IconButton(onClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = contentDescription,
+ )
+ }
+}
+
+/** Action that expends the search bar. */
+@Composable
+internal fun SearchAction(onClick: () -> Unit) {
+ IconButton(onClick) {
+ Icon(
+ imageVector = Icons.Outlined.FindInPage,
+ contentDescription = stringResource(R.string.search_menu_title),
+ )
+ }
+}
+
+/** Action that clear the search query. */
+@Composable
+internal fun ClearAction(onClick: () -> Unit) {
+ IconButton(onClick) {
+ Icon(
+ imageVector = Icons.Outlined.Clear,
+ contentDescription = stringResource(R.string.abc_searchview_description_clear),
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt
new file mode 100644
index 00000000..56534f41
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/CustomizedAppBar.kt
@@ -0,0 +1,628 @@
+/*
+ * Copyright 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.DecayAnimationSpec
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.animateDecay
+import androidx.compose.animation.core.animateTo
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.TopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.layout.AlignmentLine
+import androidx.compose.ui.layout.LastBaseline
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CustomizedTopAppBar(
+ title: @Composable () -> Unit,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ SingleRowTopAppBar(
+ title = title,
+ titleTextStyle = MaterialTheme.typography.titleMedium,
+ navigationIcon = navigationIcon,
+ actions = actions,
+ windowInsets = TopAppBarDefaults.windowInsets,
+ colors = topAppBarColors(),
+ )
+}
+
+/**
+ * The customized LargeTopAppBar for Settings.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CustomizedLargeTopAppBar(
+ title: String,
+ modifier: Modifier = Modifier,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ TwoRowsTopAppBar(
+ title = { Title(title = title, maxLines = 3) },
+ titleTextStyle = MaterialTheme.typography.displaySmall,
+ smallTitleTextStyle = MaterialTheme.typography.titleMedium,
+ titleBottomPadding = LargeTitleBottomPadding,
+ smallTitle = { Title(title = title, maxLines = 1) },
+ modifier = modifier,
+ navigationIcon = navigationIcon,
+ actions = actions,
+ colors = topAppBarColors(),
+ windowInsets = TopAppBarDefaults.windowInsets,
+ pinnedHeight = ContainerHeight,
+ scrollBehavior = scrollBehavior,
+ )
+}
+
+@Composable
+private fun Title(title: String, maxLines: Int = Int.MAX_VALUE) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(
+ start = SettingsDimension.itemPaddingAround,
+ end = SettingsDimension.itemPaddingEnd,
+ )
+ .semantics { heading() },
+ overflow = TextOverflow.Ellipsis,
+ maxLines = maxLines,
+ )
+}
+
+@Composable
+private fun topAppBarColors() = TopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader,
+ navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+)
+
+/**
+ * Represents the colors used by a top app bar in different states.
+ * This implementation animates the container color according to the top app bar scroll state. It
+ * does not animate the leading, headline, or trailing colors.
+ *
+ * @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a
+ * factory method using the default material3 spec
+ * @param containerColor the color used for the background of this BottomAppBar. Use
+ * [Color.Transparent] to have no color.
+ * @param scrolledContainerColor the container color when content is scrolled behind it
+ * @param navigationIconContentColor the content color used for the navigation icon
+ * @param titleContentColor the content color used for the title
+ * @param actionIconContentColor the content color used for actions
+ */
+@Stable
+private class TopAppBarColors(
+ val containerColor: Color,
+ val scrolledContainerColor: Color,
+ val navigationIconContentColor: Color,
+ val titleContentColor: Color,
+ val actionIconContentColor: Color,
+) {
+
+ /**
+ * Represents the container color used for the top app bar.
+ *
+ * A [colorTransitionFraction] provides a percentage value that can be used to generate a color.
+ * Usually, an app bar implementation will pass in a [colorTransitionFraction] read from
+ * the [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction].
+ *
+ * @param colorTransitionFraction a `0.0` to `1.0` value that represents a color transition
+ * percentage
+ */
+ @Stable
+ fun containerColor(colorTransitionFraction: Float): Color {
+ return lerp(
+ containerColor,
+ scrolledContainerColor,
+ FastOutLinearInEasing.transform(colorTransitionFraction)
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || other !is TopAppBarColors) return false
+
+ if (containerColor != other.containerColor) return false
+ if (scrolledContainerColor != other.scrolledContainerColor) return false
+ if (navigationIconContentColor != other.navigationIconContentColor) return false
+ if (titleContentColor != other.titleContentColor) return false
+ if (actionIconContentColor != other.actionIconContentColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = containerColor.hashCode()
+ result = 31 * result + scrolledContainerColor.hashCode()
+ result = 31 * result + navigationIconContentColor.hashCode()
+ result = 31 * result + titleContentColor.hashCode()
+ result = 31 * result + actionIconContentColor.hashCode()
+
+ return result
+ }
+}
+
+/**
+ * A single-row top app bar that is designed to be called by the small and center aligned top app
+ * bar composables.
+ */
+@Composable
+private fun SingleRowTopAppBar(
+ title: @Composable () -> Unit,
+ titleTextStyle: TextStyle,
+ navigationIcon: @Composable () -> Unit,
+ actions: @Composable (RowScope.() -> Unit),
+ windowInsets: WindowInsets,
+ colors: TopAppBarColors,
+) {
+ // Wrap the given actions in a Row.
+ val actionsRow = @Composable {
+ Row(
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ content = actions
+ )
+ }
+
+ // Compose a Surface with a TopAppBarLayout content.
+ Surface(color = colors.scrolledContainerColor) {
+ val height = LocalDensity.current.run { ContainerHeight.toPx() }
+ TopAppBarLayout(
+ modifier = Modifier
+ .windowInsetsPadding(windowInsets)
+ // clip after padding so we don't show the title over the inset area
+ .clipToBounds(),
+ heightPx = height,
+ navigationIconContentColor = colors.navigationIconContentColor,
+ titleContentColor = colors.titleContentColor,
+ actionIconContentColor = colors.actionIconContentColor,
+ title = title,
+ titleTextStyle = titleTextStyle,
+ titleAlpha = 1f,
+ titleVerticalArrangement = Arrangement.Center,
+ titleBottomPadding = 0,
+ hideTitleSemantics = false,
+ navigationIcon = navigationIcon,
+ actions = actionsRow,
+ titleScaleDisabled = false,
+ )
+ }
+}
+
+/**
+ * A two-rows top app bar that is designed to be called by the Large and Medium top app bar
+ * composables.
+ *
+ * @throws [IllegalArgumentException] if the given [MaxHeightWithoutTitle] is equal or smaller than
+ * the [pinnedHeight]
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TwoRowsTopAppBar(
+ modifier: Modifier = Modifier,
+ title: @Composable () -> Unit,
+ titleTextStyle: TextStyle,
+ titleBottomPadding: Dp,
+ smallTitle: @Composable () -> Unit,
+ smallTitleTextStyle: TextStyle,
+ navigationIcon: @Composable () -> Unit,
+ actions: @Composable RowScope.() -> Unit,
+ windowInsets: WindowInsets,
+ colors: TopAppBarColors,
+ pinnedHeight: Dp,
+ scrollBehavior: TopAppBarScrollBehavior?
+) {
+ if (MaxHeightWithoutTitle <= pinnedHeight) {
+ throw IllegalArgumentException(
+ "A TwoRowsTopAppBar max height should be greater than its pinned height"
+ )
+ }
+ val pinnedHeightPx: Float
+ val titleBottomPaddingPx: Int
+ val defaultMaxHeightPx: Float
+ val density = LocalDensity.current
+ density.run {
+ pinnedHeightPx = pinnedHeight.toPx()
+ titleBottomPaddingPx = titleBottomPadding.roundToPx()
+ defaultMaxHeightPx = (MaxHeightWithoutTitle + DefaultTitleHeight).toPx()
+ }
+
+ val maxHeightPx = remember(density) { mutableFloatStateOf(defaultMaxHeightPx) }
+
+ // Sets the app bar's height offset limit to hide just the bottom title area and keep top title
+ // visible when collapsed.
+ SideEffect {
+ if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx.floatValue) {
+ scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.floatValue
+ }
+ }
+
+ // Obtain the container Color from the TopAppBarColors using the `collapsedFraction`, as the
+ // bottom part of this TwoRowsTopAppBar changes color at the same rate the app bar expands or
+ // collapse.
+ // This will potentially animate or interpolate a transition between the container color and the
+ // container's scrolled color according to the app bar's scroll state.
+ val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction ?: 0f
+ val appBarContainerColor = colors.containerColor(colorTransitionFraction)
+
+ // Wrap the given actions in a Row.
+ val actionsRow = @Composable {
+ Row(
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ content = actions
+ )
+ }
+ val topTitleAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction)
+ val bottomTitleAlpha = 1f - colorTransitionFraction
+ // Hide the top row title semantics when its alpha value goes below 0.5 threshold.
+ // Hide the bottom row title semantics when the top title semantics are active.
+ val hideTopRowSemantics = colorTransitionFraction < 0.5f
+ val hideBottomRowSemantics = !hideTopRowSemantics
+
+ // Set up support for resizing the top app bar when vertically dragging the bar itself.
+ val appBarDragModifier = if (scrollBehavior != null && !scrollBehavior.isPinned) {
+ Modifier.draggable(
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { delta ->
+ scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffset + delta
+ },
+ onDragStopped = { velocity ->
+ settleAppBar(
+ scrollBehavior.state,
+ velocity,
+ scrollBehavior.flingAnimationSpec,
+ scrollBehavior.snapAnimationSpec
+ )
+ }
+ )
+ } else {
+ Modifier
+ }
+
+ Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) {
+ Column {
+ TopAppBarLayout(
+ modifier = Modifier
+ .windowInsetsPadding(windowInsets)
+ // clip after padding so we don't show the title over the inset area
+ .clipToBounds(),
+ heightPx = pinnedHeightPx,
+ navigationIconContentColor = colors.navigationIconContentColor,
+ titleContentColor = colors.titleContentColor,
+ actionIconContentColor = colors.actionIconContentColor,
+ title = smallTitle,
+ titleTextStyle = smallTitleTextStyle,
+ titleAlpha = topTitleAlpha,
+ titleVerticalArrangement = Arrangement.Center,
+ titleBottomPadding = 0,
+ hideTitleSemantics = hideTopRowSemantics,
+ navigationIcon = navigationIcon,
+ actions = actionsRow,
+ )
+ TopAppBarLayout(
+ modifier = Modifier
+ // only apply the horizontal sides of the window insets padding, since the top
+ // padding will always be applied by the layout above
+ .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal))
+ .clipToBounds(),
+ heightPx = maxHeightPx.floatValue - pinnedHeightPx +
+ (scrollBehavior?.state?.heightOffset ?: 0f),
+ navigationIconContentColor = colors.navigationIconContentColor,
+ titleContentColor = colors.titleContentColor,
+ actionIconContentColor = colors.actionIconContentColor,
+ title = {
+ Box(modifier = Modifier.onGloballyPositioned { coordinates ->
+ val measuredMaxHeightPx = density.run {
+ MaxHeightWithoutTitle.toPx() + coordinates.size.height.toFloat()
+ }
+ // Allow larger max height for multi-line title, but do not reduce
+ // max height to prevent flaky.
+ if (measuredMaxHeightPx > defaultMaxHeightPx) {
+ maxHeightPx.floatValue = measuredMaxHeightPx
+ }
+ }) { title() }
+ },
+ titleTextStyle = titleTextStyle,
+ titleAlpha = bottomTitleAlpha,
+ titleVerticalArrangement = Arrangement.Bottom,
+ titleBottomPadding = titleBottomPaddingPx,
+ hideTitleSemantics = hideBottomRowSemantics,
+ navigationIcon = {},
+ actions = {}
+ )
+ }
+ }
+}
+
+/**
+ * The base [Layout] for all top app bars. This function lays out a top app bar navigation icon
+ * (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and
+ * the actions are optional.
+ *
+ * @param heightPx the total height this layout is capped to
+ * @param navigationIconContentColor the content color that will be applied via a
+ * [LocalContentColor] when composing the navigation icon
+ * @param titleContentColor the color that will be applied via a [LocalContentColor] when composing
+ * the title
+ * @param actionIconContentColor the content color that will be applied via a [LocalContentColor]
+ * when composing the action icons
+ * @param title the top app bar title (header)
+ * @param titleTextStyle the title's text style
+ * @param modifier a [Modifier]
+ * @param titleAlpha the title's alpha
+ * @param titleVerticalArrangement the title's vertical arrangement
+ * @param titleBottomPadding the title's bottom padding
+ * @param hideTitleSemantics hides the title node from the semantic tree. Apply this
+ * boolean when this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics
+ * from accessibility services. This is needed to avoid having multiple titles visible to
+ * accessibility services at the same time, when animating between collapsed / expanded states.
+ * @param navigationIcon a navigation icon [Composable]
+ * @param actions actions [Composable]
+ * @param titleScaleDisabled whether the title font scaling is disabled. Default is disabled.
+ */
+@Composable
+private fun TopAppBarLayout(
+ modifier: Modifier,
+ heightPx: Float,
+ navigationIconContentColor: Color,
+ titleContentColor: Color,
+ actionIconContentColor: Color,
+ title: @Composable () -> Unit,
+ titleTextStyle: TextStyle,
+ titleAlpha: Float,
+ titleVerticalArrangement: Arrangement.Vertical,
+ titleBottomPadding: Int,
+ hideTitleSemantics: Boolean,
+ navigationIcon: @Composable () -> Unit,
+ actions: @Composable () -> Unit,
+ titleScaleDisabled: Boolean = true,
+) {
+ Layout(
+ {
+ Box(
+ Modifier
+ .layoutId("navigationIcon")
+ .padding(start = TopAppBarHorizontalPadding)
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides navigationIconContentColor,
+ content = navigationIcon
+ )
+ }
+ Box(
+ Modifier
+ .layoutId("title")
+ .padding(horizontal = TopAppBarHorizontalPadding)
+ .then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier)
+ .graphicsLayer(alpha = titleAlpha)
+ ) {
+ ProvideTextStyle(value = titleTextStyle) {
+ CompositionLocalProvider(
+ LocalContentColor provides titleContentColor,
+ LocalDensity provides with(LocalDensity.current) {
+ Density(
+ density = density,
+ fontScale = if (titleScaleDisabled) 1f else fontScale,
+ )
+ },
+ content = title
+ )
+ }
+ }
+ Box(
+ Modifier
+ .layoutId("actionIcons")
+ .padding(end = TopAppBarHorizontalPadding)
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides actionIconContentColor,
+ content = actions
+ )
+ }
+ },
+ modifier = modifier
+ ) { measurables, constraints ->
+ val navigationIconPlaceable =
+ measurables.first { it.layoutId == "navigationIcon" }
+ .measure(constraints.copy(minWidth = 0))
+ val actionIconsPlaceable =
+ measurables.first { it.layoutId == "actionIcons" }
+ .measure(constraints.copy(minWidth = 0))
+
+ val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) {
+ constraints.maxWidth
+ } else {
+ (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width)
+ .coerceAtLeast(0)
+ }
+ val titlePlaceable =
+ measurables.first { it.layoutId == "title" }
+ .measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth))
+
+ // Locate the title's baseline.
+ val titleBaseline =
+ if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) {
+ titlePlaceable[LastBaseline]
+ } else {
+ 0
+ }
+
+ val layoutHeight = if (heightPx > 0) heightPx.roundToInt() else 0
+
+ layout(constraints.maxWidth, layoutHeight) {
+ // Navigation icon
+ navigationIconPlaceable.placeRelative(
+ x = 0,
+ y = (layoutHeight - navigationIconPlaceable.height) / 2
+ )
+
+ // Title
+ titlePlaceable.placeRelative(
+ x = max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width),
+ y = when (titleVerticalArrangement) {
+ Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2
+ // Apply bottom padding from the title's baseline only when the Arrangement is
+ // "Bottom".
+ Arrangement.Bottom ->
+ if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height
+ else layoutHeight - titlePlaceable.height - max(
+ 0,
+ titleBottomPadding - titlePlaceable.height + titleBaseline
+ )
+ // Arrangement.Top
+ else -> 0
+ }
+ )
+
+ // Action icons
+ actionIconsPlaceable.placeRelative(
+ x = constraints.maxWidth - actionIconsPlaceable.width,
+ y = (layoutHeight - actionIconsPlaceable.height) / 2
+ )
+ }
+ }
+}
+
+
+/**
+ * Settles the app bar by flinging, in case the given velocity is greater than zero, and snapping
+ * after the fling settles.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+private suspend fun settleAppBar(
+ state: TopAppBarState,
+ velocity: Float,
+ flingAnimationSpec: DecayAnimationSpec?,
+ snapAnimationSpec: AnimationSpec?
+): Velocity {
+ // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
+ // and just return Zero Velocity.
+ // Note that we don't check for 0f due to float precision with the collapsedFraction
+ // calculation.
+ if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
+ return Velocity.Zero
+ }
+ var remainingVelocity = velocity
+ // In case there is an initial velocity that was left after a previous user fling, animate to
+ // continue the motion to expand or collapse the app bar.
+ if (flingAnimationSpec != null && abs(velocity) > 1f) {
+ var lastValue = 0f
+ AnimationState(
+ initialValue = 0f,
+ initialVelocity = velocity,
+ )
+ .animateDecay(flingAnimationSpec) {
+ val delta = value - lastValue
+ val initialHeightOffset = state.heightOffset
+ state.heightOffset = initialHeightOffset + delta
+ val consumed = abs(initialHeightOffset - state.heightOffset)
+ lastValue = value
+ remainingVelocity = this.velocity
+ // avoid rounding errors and stop if anything is unconsumed
+ if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
+ }
+ }
+ // Snap if animation specs were provided.
+ if (snapAnimationSpec != null) {
+ if (state.heightOffset < 0 &&
+ state.heightOffset > state.heightOffsetLimit
+ ) {
+ AnimationState(initialValue = state.heightOffset).animateTo(
+ if (state.collapsedFraction < 0.5f) {
+ 0f
+ } else {
+ state.heightOffsetLimit
+ },
+ animationSpec = snapAnimationSpec
+ ) { state.heightOffset = value }
+ }
+ }
+
+ return Velocity(0f, remainingVelocity)
+}
+
+// An easing function used to compute the alpha value that is applied to the top title part of a
+// Medium or Large app bar.
+private val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f)
+
+internal val MaxHeightWithoutTitle = 124.dp
+internal val DefaultTitleHeight = 52.dp
+internal val ContainerHeight = 56.dp
+private val LargeTitleBottomPadding = 28.dp
+private val TopAppBarHorizontalPadding = 4.dp
+
+// A title inset when the App-Bar is a Medium or Large one. Also used to size a spacer when the
+// navigation icon is missing.
+private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/HomeScaffold.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/HomeScaffold.kt
new file mode 100644
index 00000000..711c8a75
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/HomeScaffold.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+
+@Composable
+fun HomeScaffold(title: String, content: @Composable () -> Unit) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colorScheme.background)
+ .systemBarsPadding()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(SettingsDimension.itemPadding),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.headlineMedium,
+ )
+
+ content()
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/MoreOptions.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/MoreOptions.kt
new file mode 100644
index 00000000..d92a863e
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/MoreOptions.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.appcompat.R
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+
+/**
+ * Scope for the children of [MoreOptionsAction].
+ */
+abstract class MoreOptionsScope {
+ abstract fun dismiss()
+
+ @Composable
+ fun MenuItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
+ DropdownMenuItem(
+ text = { Text(text) },
+ onClick = {
+ dismiss()
+ onClick()
+ },
+ enabled = enabled,
+ )
+ }
+}
+
+@Composable
+fun MoreOptionsAction(
+ content: @Composable MoreOptionsScope.() -> Unit,
+) {
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ MoreOptionsActionButton { expanded = true }
+ val onDismiss = { expanded = false }
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
+ val moreOptionsScope = remember(this) {
+ object : MoreOptionsScope() {
+ override fun dismiss() {
+ onDismiss()
+ }
+ }
+ }
+ moreOptionsScope.content()
+ }
+}
+
+@Composable
+private fun MoreOptionsActionButton(onClick: () -> Unit) {
+ IconButton(onClick) {
+ Icon(
+ imageVector = Icons.Outlined.MoreVert,
+ contentDescription = stringResource(R.string.abc_action_menu_overflow_description),
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt
new file mode 100644
index 00000000..d17a8dcd
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/RegularScaffold.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+
+/**
+ * A [Scaffold] which content is scrollable and wrapped in a [Column].
+ *
+ * For example, this is for the pages with some preferences and is scrollable when the items out of
+ * the screen.
+ */
+@Composable
+fun RegularScaffold(
+ title: String,
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable () -> Unit,
+) {
+ SettingsScaffold(title, actions) { paddingValues ->
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Spacer(Modifier.height(paddingValues.calculateTopPadding()))
+ content()
+ Spacer(Modifier.height(paddingValues.calculateBottomPadding()))
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun RegularScaffoldPreview() {
+ SettingsTheme {
+ RegularScaffold(title = "Display") {
+ Preference(object : PreferenceModel {
+ override val title = "Item 1"
+ })
+ Preference(object : PreferenceModel {
+ override val title = "Item 2"
+ })
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt
new file mode 100644
index 00000000..c87178db
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SearchScaffold.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.activity.compose.BackHandler
+import androidx.appcompat.R
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.android.settingslib.spa.framework.compose.hideKeyboardAction
+import com.android.settingslib.spa.framework.compose.horizontalValues
+import com.android.settingslib.spa.framework.theme.SettingsOpacity
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+
+/**
+ * A [Scaffold] which content is can be full screen, and with a search feature built-in.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchScaffold(
+ title: String,
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable (bottomPadding: Dp, searchQuery: () -> String) -> Unit,
+) {
+ ActivityTitle(title)
+ var isSearchMode by rememberSaveable { mutableStateOf(false) }
+ val viewModel: SearchScaffoldViewModel = viewModel()
+
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ SearchableTopAppBar(
+ title = title,
+ actions = actions,
+ scrollBehavior = scrollBehavior,
+ isSearchMode = isSearchMode,
+ onSearchModeChange = { isSearchMode = it },
+ searchQuery = viewModel.searchQuery,
+ onSearchQueryChange = { viewModel.searchQuery = it },
+ )
+ },
+ ) { paddingValues ->
+ Box(
+ Modifier
+ .padding(paddingValues.horizontalValues())
+ .padding(top = paddingValues.calculateTopPadding())
+ .focusable()
+ .fillMaxSize()
+ ) {
+ content(paddingValues.calculateBottomPadding()) {
+ if (isSearchMode) viewModel.searchQuery.text else ""
+ }
+ }
+ }
+}
+
+internal class SearchScaffoldViewModel : ViewModel() {
+ // Put in view model because TextFieldValue has not default Saver for rememberSaveable.
+ var searchQuery by mutableStateOf(TextFieldValue())
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SearchableTopAppBar(
+ title: String,
+ actions: @Composable RowScope.() -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior,
+ isSearchMode: Boolean,
+ onSearchModeChange: (Boolean) -> Unit,
+ searchQuery: TextFieldValue,
+ onSearchQueryChange: (TextFieldValue) -> Unit,
+) {
+ if (isSearchMode) {
+ SearchTopAppBar(
+ query = searchQuery,
+ onQueryChange = onSearchQueryChange,
+ onClose = { onSearchModeChange(false) },
+ actions = actions,
+ )
+ } else {
+ SettingsTopAppBar(title, scrollBehavior) {
+ SearchAction {
+ scrollBehavior.collapse()
+ onSearchQueryChange(TextFieldValue())
+ onSearchModeChange(true)
+ }
+ actions()
+ }
+ }
+}
+
+@Composable
+private fun SearchTopAppBar(
+ query: TextFieldValue,
+ onQueryChange: (TextFieldValue) -> Unit,
+ onClose: () -> Unit,
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ CustomizedTopAppBar(
+ title = { SearchBox(query, onQueryChange) },
+ navigationIcon = { CollapseAction(onClose) },
+ actions = {
+ if (query.text.isNotEmpty()) {
+ ClearAction { onQueryChange(TextFieldValue()) }
+ }
+ actions()
+ },
+ )
+ BackHandler { onClose() }
+}
+
+@Composable
+private fun SearchBox(query: TextFieldValue, onQueryChange: (TextFieldValue) -> Unit) {
+ val focusRequester = remember { FocusRequester() }
+ val textStyle = MaterialTheme.typography.bodyLarge
+ val hideKeyboardAction = hideKeyboardAction()
+ TextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ textStyle = textStyle,
+ placeholder = {
+ Text(
+ text = stringResource(R.string.abc_search_hint),
+ modifier = Modifier.alpha(SettingsOpacity.Hint),
+ style = textStyle,
+ )
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { hideKeyboardAction() }),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ )
+
+ LaunchedEffect(focusRequester) {
+ focusRequester.requestFocus()
+ }
+}
+
+@Preview
+@Composable
+private fun SearchTopAppBarPreview() {
+ SettingsTheme {
+ SearchTopAppBar(query = TextFieldValue(), onQueryChange = {}, onClose = {}) {}
+ }
+}
+
+@Preview
+@Composable
+private fun SearchScaffoldPreview() {
+ SettingsTheme {
+ SearchScaffold(title = "App notifications") { _, _ ->
+ Column {
+ Preference(object : PreferenceModel {
+ override val title = "Item 1"
+ })
+ Preference(object : PreferenceModel {
+ override val title = "Item 2"
+ })
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt
new file mode 100644
index 00000000..aa148b02
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsPager.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.TabRow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import kotlin.math.absoluteValue
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SettingsPager(titles: List, content: @Composable (page: Int) -> Unit) {
+ check(titles.isNotEmpty())
+ if (titles.size == 1) {
+ content(0)
+ return
+ }
+
+ Column {
+ val coroutineScope = rememberCoroutineScope()
+ val pagerState = rememberPagerState { titles.size }
+
+ TabRow(
+ selectedTabIndex = pagerState.currentPage,
+ modifier = Modifier.padding(horizontal = SettingsDimension.itemPaddingEnd),
+ containerColor = Color.Transparent,
+ indicator = {},
+ divider = {},
+ ) {
+ titles.forEachIndexed { page, title ->
+ SettingsTab(
+ title = title,
+ selected = pagerState.currentPage == page,
+ currentPageOffset = pagerState.currentPageOffsetFraction.absoluteValue,
+ onClick = {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(page)
+ }
+ },
+ )
+ }
+ }
+
+ HorizontalPager(state = pagerState) { page ->
+ content(page)
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt
new file mode 100644
index 00000000..89194021
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffold.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.compose.horizontalValues
+import com.android.settingslib.spa.framework.compose.verticalValues
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+
+/**
+ * A [Scaffold] which content is can be full screen when needed.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScaffold(
+ title: String,
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable (PaddingValues) -> Unit,
+) {
+ ActivityTitle(title)
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = { SettingsTopAppBar(title, scrollBehavior, actions) },
+ ) { paddingValues ->
+ Box(Modifier.padding(paddingValues.horizontalValues())) {
+ content(paddingValues.verticalValues())
+ }
+ }
+}
+
+/**
+ * Sets a title for the activity.
+ *
+ * So the TalkBack can read out the correct page title.
+ */
+@Composable
+internal fun ActivityTitle(title: String) {
+ val context = LocalContext.current
+ LaunchedEffect(true) {
+ context.getActivity()?.title = title
+ }
+}
+
+private fun Context.getActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.getActivity()
+ else -> null
+}
+
+@Preview
+@Composable
+private fun SettingsScaffoldPreview() {
+ SettingsTheme {
+ SettingsScaffold(title = "Display") { paddingValues ->
+ Column(Modifier.padding(paddingValues)) {
+ Preference(object : PreferenceModel {
+ override val title = "Item 1"
+ })
+ Preference(object : PreferenceModel {
+ override val title = "Item 2"
+ })
+ }
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt
new file mode 100644
index 00000000..6f2c38ca
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTab.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsShape
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+internal fun SettingsTab(
+ title: String,
+ selected: Boolean,
+ currentPageOffset: Float,
+ onClick: () -> Unit,
+) {
+ // Shows a color transition during pager scroll.
+ // 0f -> Selected, 1f -> Not selected
+ val colorFraction = if (selected) (currentPageOffset * 2).coerceAtMost(1f) else 1f
+ Tab(
+ selected = selected,
+ onClick = onClick,
+ modifier = Modifier
+ .minimumInteractiveComponentSize()
+ .padding(horizontal = 4.dp, vertical = 6.dp)
+ .clip(SettingsShape.CornerMedium)
+ .background(
+ color = lerp(
+ start = SettingsTheme.colorScheme.primaryContainer,
+ stop = SettingsTheme.colorScheme.surface,
+ fraction = colorFraction,
+ ),
+ ),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelLarge,
+ color = lerp(
+ start = SettingsTheme.colorScheme.onPrimaryContainer,
+ stop = SettingsTheme.colorScheme.secondaryText,
+ fraction = colorFraction,
+ ),
+ )
+ }
+}
+
+@Preview
+@Composable
+fun SettingsTabPreview() {
+ SettingsTheme {
+ Column {
+ SettingsTab(title = "Personal", selected = true, currentPageOffset = 0f) {}
+ SettingsTab(title = "Work", selected = false, currentPageOffset = 0f) {}
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTopAppBar.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTopAppBar.kt
new file mode 100644
index 00000000..33117923
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SettingsTopAppBar.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun SettingsTopAppBar(
+ title: String,
+ scrollBehavior: TopAppBarScrollBehavior,
+ actions: @Composable RowScope.() -> Unit,
+) {
+ CustomizedLargeTopAppBar(
+ title = title,
+ navigationIcon = { NavigateBack() },
+ actions = actions,
+ scrollBehavior = scrollBehavior,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+internal fun TopAppBarScrollBehavior.collapse() {
+ with(state) {
+ heightOffset = heightOffsetLimit
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/scaffold/SuwScaffold.kt b/spa/src/com/android/settingslib/spa/widget/scaffold/SuwScaffold.kt
new file mode 100644
index 00000000..354b95dd
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/scaffold/SuwScaffold.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.scaffold
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.toMediumWeight
+
+data class BottomAppBarButton(
+ val text: String,
+ val onClick: () -> Unit,
+)
+
+@Composable
+fun SuwScaffold(
+ imageVector: ImageVector,
+ title: String,
+ actionButton: BottomAppBarButton? = null,
+ dismissButton: BottomAppBarButton? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ ActivityTitle(title)
+ Scaffold { innerPadding ->
+ BoxWithConstraints(
+ Modifier
+ .padding(innerPadding)
+ .padding(top = SettingsDimension.itemPaddingAround)
+ ) {
+ // Use single column layout in portrait, two columns in landscape.
+ val useSingleColumn = maxWidth < maxHeight
+ if (useSingleColumn) {
+ Column {
+ Column(
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Header(imageVector, title)
+ content()
+ }
+ BottomBar(actionButton, dismissButton)
+ }
+ } else {
+ Column(Modifier.padding(horizontal = SettingsDimension.itemPaddingAround)) {
+ Row((Modifier.weight(1f))) {
+ Box(Modifier.weight(1f)) {
+ Header(imageVector, title)
+ }
+ Column(
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())) {
+ content()
+ }
+ }
+ BottomBar(actionButton, dismissButton)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Header(
+ imageVector: ImageVector,
+ title: String
+) {
+ Column(Modifier.padding(SettingsDimension.itemPadding)) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(vertical = SettingsDimension.itemPaddingAround)
+ .size(SettingsDimension.iconLarge),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = title,
+ modifier = Modifier.padding(vertical = SettingsDimension.itemPaddingVertical),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.displaySmall,
+ )
+ }
+}
+
+@Composable
+private fun BottomBar(
+ actionButton: BottomAppBarButton?,
+ dismissButton: BottomAppBarButton?,
+) {
+ Row(modifier = Modifier.padding(SettingsDimension.itemPaddingAround)) {
+ dismissButton?.apply {
+ TextButton(onClick) {
+ ActionText(text)
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ actionButton?.apply {
+ Button(onClick) {
+ ActionText(text)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ActionText(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium.toMediumWeight(),
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/AnnotatedText.kt b/spa/src/com/android/settingslib/spa/widget/ui/AnnotatedText.kt
new file mode 100644
index 00000000..82ac7e3b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/AnnotatedText.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.ui
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalUriHandler
+import com.android.settingslib.spa.framework.util.URL_SPAN_TAG
+import com.android.settingslib.spa.framework.util.annotatedStringResource
+
+@Composable
+fun AnnotatedText(@StringRes id: Int) {
+ val uriHandler = LocalUriHandler.current
+ val annotatedString = annotatedStringResource(id)
+ ClickableText(
+ text = annotatedString,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ ) { offset ->
+ // Gets the url at the clicked position.
+ annotatedString.getStringAnnotations(URL_SPAN_TAG, offset, offset)
+ .firstOrNull()
+ ?.let { uriHandler.openUri(it.item) }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Category.kt b/spa/src/com/android/settingslib/spa/widget/ui/Category.kt
new file mode 100644
index 00000000..6aac5bf3
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Category.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+/**
+ * A category title that is placed before a group of similar items.
+ */
+@Composable
+fun CategoryTitle(title: String) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(
+ start = SettingsDimension.itemPaddingStart,
+ top = 20.dp,
+ end = SettingsDimension.itemPaddingEnd,
+ bottom = 8.dp,
+ ),
+ color = SettingsTheme.colorScheme.categoryTitle,
+ style = MaterialTheme.typography.labelMedium,
+ )
+}
+
+/**
+ * A container that is used to group similar items. A [Category] displays a [CategoryTitle] and
+ * visually separates groups of items.
+ */
+@Composable
+fun Category(title: String, content: @Composable ColumnScope.() -> Unit) {
+ Column {
+ var displayTitle by remember { mutableStateOf(false) }
+ if (displayTitle) CategoryTitle(title = title)
+ Column(
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ displayTitle = coordinates.size.height > 0
+ },
+ content = content,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CategoryPreview() {
+ SettingsTheme {
+ CategoryTitle("Appearance")
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Checkbox.kt b/spa/src/com/android/settingslib/spa/widget/ui/Checkbox.kt
new file mode 100644
index 00000000..7a9d46cb
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Checkbox.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.Checkbox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
+
+@Composable
+internal fun SettingsCheckbox(
+ checked: Boolean?,
+ changeable: () -> Boolean,
+ onCheckedChange: ((newChecked: Boolean) -> Unit)? = null,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ if (checked != null) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = wrapOnSwitchWithLog(onCheckedChange),
+ enabled = changeable(),
+ interactionSource = interactionSource,
+ )
+ } else {
+ Checkbox(
+ checked = false,
+ onCheckedChange = null,
+ enabled = false,
+ interactionSource = interactionSource,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/CopyableBody.kt b/spa/src/com/android/settingslib/spa/widget/ui/CopyableBody.kt
new file mode 100644
index 00000000..930d0a18
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/CopyableBody.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.DpOffset
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+fun CopyableBody(body: String) {
+ var expanded by remember { mutableStateOf(false) }
+ var dpOffset by remember { mutableStateOf(DpOffset.Unspecified) }
+
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ dpOffset = DpOffset(it.x.toDp(), it.y.toDp())
+ expanded = true
+ },
+ )
+ }
+ ) {
+ SettingsBody(body)
+
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ offset = dpOffset,
+ ) {
+ DropdownMenuTitle(body)
+ DropdownMenuCopy(body) { expanded = false }
+ }
+ }
+}
+
+@Composable
+private fun DropdownMenuTitle(text: String) {
+ Text(
+ text = text,
+ modifier = Modifier
+ .padding(MenuDefaults.DropdownMenuItemContentPadding)
+ .padding(
+ top = SettingsDimension.itemPaddingAround,
+ bottom = SettingsDimension.buttonPaddingVertical,
+ ),
+ color = SettingsTheme.colorScheme.categoryTitle,
+ style = MaterialTheme.typography.labelMedium,
+ )
+}
+
+@Composable
+private fun DropdownMenuCopy(body: String, onCopy: () -> Unit) {
+ val clipboardManager = LocalClipboardManager.current
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(android.R.string.copy),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ onClick = {
+ onCopy()
+ clipboardManager.setText(AnnotatedString(body))
+ }
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Footer.kt b/spa/src/com/android/settingslib/spa/widget/ui/Footer.kt
new file mode 100644
index 00000000..d9d3b37a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Footer.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+@Composable
+fun Footer(footerText: String) {
+ if (footerText.isEmpty()) return
+ Footer {
+ SettingsBody(footerText)
+ }
+}
+
+@Composable
+fun Footer(content: @Composable () -> Unit) {
+ Column(Modifier.padding(SettingsDimension.itemPadding)) {
+ Icon(
+ imageVector = Icons.Outlined.Info,
+ contentDescription = null,
+ modifier = Modifier.size(SettingsDimension.itemIconSize),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(SettingsDimension.itemPaddingVertical))
+ content()
+ }
+}
+
+@Preview
+@Composable
+private fun FooterPreview() {
+ SettingsTheme {
+ Footer("Footer text always at the end of page.")
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Icon.kt b/spa/src/com/android/settingslib/spa/widget/ui/Icon.kt
new file mode 100644
index 00000000..25a4e708
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Icon.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+
+@Composable
+fun SettingsIcon(imageVector: ImageVector) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(SettingsDimension.itemIconSize),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
+fun createSettingsIcon(imageVector: ImageVector?): (@Composable () -> Unit)? {
+ if (imageVector == null) return null
+ return { SettingsIcon(imageVector = imageVector) }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Image.kt b/spa/src/com/android/settingslib/spa/widget/ui/Image.kt
new file mode 100644
index 00000000..0790bf8b
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Image.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+
+@Composable
+fun ImageBox(
+ resId: Int,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ Image(
+ painter = painterResource(resId),
+ contentDescription = contentDescription,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/LoadingBar.kt b/spa/src/com/android/settingslib/spa/widget/ui/LoadingBar.kt
new file mode 100644
index 00000000..1741f134
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/LoadingBar.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Indeterminate linear progress bar. Expresses an unspecified wait time.
+ */
+@Composable
+fun LinearLoadingBar(
+ isLoading: Boolean,
+ xOffset: Dp = 0.dp,
+ yOffset: Dp = 0.dp
+) {
+ if (isLoading) {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .absoluteOffset(xOffset, yOffset)
+ )
+ }
+}
+
+/**
+ * Indeterminate circular progress bar. Expresses an unspecified wait time.
+ */
+@Composable
+fun CircularLoadingBar(isLoading: Boolean) {
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Lottie.kt b/spa/src/com/android/settingslib/spa/widget/ui/Lottie.kt
new file mode 100644
index 00000000..a6cc3a9a
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Lottie.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.res.colorResource
+import com.airbnb.lottie.LottieProperty
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.animateLottieCompositionAsState
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.compose.rememberLottieDynamicProperties
+import com.airbnb.lottie.compose.rememberLottieDynamicProperty
+import com.android.settingslib.color.R
+
+@Composable
+fun Lottie(
+ resId: Int,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier,
+ ) {
+ BaseLottie(resId)
+ }
+}
+
+object LottieColorUtils {
+ private val DARK_TO_LIGHT_THEME_COLOR_MAP = mapOf(
+ ".grey600" to R.color.settingslib_color_grey400,
+ ".grey800" to R.color.settingslib_color_grey300,
+ ".grey900" to R.color.settingslib_color_grey50,
+ ".red400" to R.color.settingslib_color_red600,
+ ".black" to android.R.color.white,
+ ".blue400" to R.color.settingslib_color_blue600,
+ ".green400" to R.color.settingslib_color_green600,
+ ".green200" to R.color.settingslib_color_green500,
+ ".red200" to R.color.settingslib_color_red500,
+ )
+
+ @Composable
+ private fun getDefaultPropertiesList() =
+ DARK_TO_LIGHT_THEME_COLOR_MAP.map { (key, colorRes) ->
+ val color = colorResource(colorRes).toArgb()
+ rememberLottieDynamicProperty(
+ property = LottieProperty.COLOR_FILTER,
+ keyPath = arrayOf("**", key, "**")
+ ){ PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) }
+ }
+
+ @Composable
+ fun getDefaultDynamicProperties() =
+ rememberLottieDynamicProperties(*getDefaultPropertiesList().toTypedArray())
+}
+
+@Composable
+private fun BaseLottie(resId: Int) {
+ val composition by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(resId)
+ )
+ val progress by animateLottieCompositionAsState(
+ composition,
+ iterations = LottieConstants.IterateForever,
+ )
+ val isLightMode = !isSystemInDarkTheme()
+ LottieAnimation(
+ composition = composition,
+ dynamicProperties = LottieColorUtils.getDefaultDynamicProperties().takeIf { isLightMode },
+ progress = { progress },
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/ProgressBar.kt b/spa/src/com/android/settingslib/spa/widget/ui/ProgressBar.kt
new file mode 100644
index 00000000..2988be84
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/ProgressBar.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+
+/**
+ * Determinate linear progress bar. Displays the current progress of the whole process.
+ *
+ * Rounded corner is supported and enabled by default.
+ */
+@Composable
+fun LinearProgressBar(
+ progress: Float,
+ height: Float = 4f,
+ roundedCorner: Boolean = true
+) {
+ Box(modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)) {
+ val color = MaterialTheme.colorScheme.onSurface
+ val trackColor = MaterialTheme.colorScheme.surfaceVariant
+ Canvas(
+ Modifier
+ .progressSemantics(progress)
+ .fillMaxWidth()
+ .height(height.dp)
+ ) {
+ drawLinearBarTrack(trackColor, roundedCorner)
+ drawLinearBar(progress, color, roundedCorner)
+ }
+ }
+}
+
+private fun DrawScope.drawLinearBar(
+ progress: Float,
+ color: Color,
+ roundedCorner: Boolean
+) {
+ val isLtr = layoutDirection == LayoutDirection.Ltr
+ val width = progress * size.width
+ drawRoundRect(
+ color = color,
+ topLeft = if (isLtr) Offset.Zero else Offset((1 - progress) * size.width, 0f),
+ size = Size(width, size.height),
+ cornerRadius = if (roundedCorner) CornerRadius(
+ size.height / 2,
+ size.height / 2
+ ) else CornerRadius.Zero,
+ )
+}
+
+private fun DrawScope.drawLinearBarTrack(
+ color: Color,
+ roundedCorner: Boolean
+) = drawLinearBar(1f, color, roundedCorner)
+
+/**
+ * Determinate circular progress bar. Displays the current progress of the whole process.
+ *
+ * Displayed in default material3 style, and rounded corner is not supported.
+ */
+@Composable
+fun CircularProgressBar(progress: Float, radius: Float = 40f) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(radius.dp, radius.dp),
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/SettingsSlider.kt b/spa/src/com/android/settingslib/spa/widget/ui/SettingsSlider.kt
new file mode 100644
index 00000000..48fec3bd
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/SettingsSlider.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.android.settingslib.spa.framework.theme.surfaceTone
+import kotlin.math.roundToInt
+
+@Composable
+fun SettingsSlider(
+ initValue: Int,
+ modifier: Modifier = Modifier,
+ valueRange: IntRange = 0..100,
+ onValueChange: ((value: Int) -> Unit)? = null,
+ onValueChangeFinished: (() -> Unit)? = null,
+ showSteps: Boolean = false,
+) {
+ var sliderPosition by rememberSaveable { mutableStateOf(initValue.toFloat()) }
+ Slider(
+ value = sliderPosition,
+ onValueChange = {
+ sliderPosition = it
+ onValueChange?.invoke(sliderPosition.roundToInt())
+ },
+ modifier = modifier,
+ valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),
+ steps = if (showSteps) (valueRange.count() - 2) else 0,
+ onValueChangeFinished = onValueChangeFinished,
+ colors = SliderDefaults.colors(
+ inactiveTrackColor = MaterialTheme.colorScheme.surfaceTone
+ )
+ )
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt b/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt
new file mode 100644
index 00000000..514ad669
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Spinner.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ExpandLess
+import androidx.compose.material.icons.outlined.ExpandMore
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+
+data class SpinnerOption(
+ val id: Int,
+ val text: String,
+)
+
+@Composable
+fun Spinner(options: List, selectedId: Int?, setId: (id: Int) -> Unit) {
+ if (options.isEmpty()) {
+ return
+ }
+
+ var expanded by rememberSaveable { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .padding(
+ start = SettingsDimension.itemPaddingStart,
+ top = SettingsDimension.itemPaddingAround,
+ end = SettingsDimension.itemPaddingEnd,
+ bottom = SettingsDimension.itemPaddingAround,
+ )
+ .selectableGroup(),
+ ) {
+ val contentPadding = PaddingValues(horizontal = SettingsDimension.itemPaddingEnd)
+ Button(
+ modifier = Modifier.semantics { role = Role.DropdownList },
+ onClick = { expanded = true },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = SettingsTheme.colorScheme.spinnerHeaderContainer,
+ contentColor = SettingsTheme.colorScheme.onSpinnerHeaderContainer,
+ ),
+ contentPadding = contentPadding,
+ ) {
+ SpinnerText(options.find { it.id == selectedId })
+ ExpandIcon(expanded)
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(SettingsTheme.colorScheme.spinnerItemContainer),
+ ) {
+ for (option in options) {
+ DropdownMenuItem(
+ text = {
+ SpinnerText(
+ option = option,
+ modifier = Modifier.padding(end = 24.dp),
+ color = SettingsTheme.colorScheme.onSpinnerItemContainer,
+ )
+ },
+ onClick = {
+ expanded = false
+ setId(option.id)
+ },
+ contentPadding = contentPadding,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ExpandIcon(expanded: Boolean) {
+ Icon(
+ imageVector = when {
+ expanded -> Icons.Outlined.ExpandLess
+ else -> Icons.Outlined.ExpandMore
+ },
+ contentDescription = null,
+ )
+}
+
+@Composable
+private fun SpinnerText(
+ option: SpinnerOption?,
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+) {
+ Text(
+ text = option?.text ?: "",
+ modifier = modifier
+ .padding(end = SettingsDimension.itemPaddingEnd)
+ .padding(vertical = SettingsDimension.itemPaddingAround),
+ color = color,
+ style = MaterialTheme.typography.labelLarge,
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SpinnerPreview() {
+ SettingsTheme {
+ var selectedId by rememberSaveable { mutableStateOf(1) }
+ Spinner(
+ options = (1..3).map { SpinnerOption(id = it, text = "Option $it") },
+ selectedId = selectedId,
+ setId = { selectedId = it },
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Switch.kt b/spa/src/com/android/settingslib/spa/widget/ui/Switch.kt
new file mode 100644
index 00000000..a0da2418
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Switch.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.Switch
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
+
+@Composable
+internal fun SettingsSwitch(
+ checked: Boolean?,
+ changeable: () -> Boolean,
+ onCheckedChange: ((newChecked: Boolean) -> Unit)? = null,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ if (checked != null) {
+ Switch(
+ checked = checked,
+ onCheckedChange = wrapOnSwitchWithLog(onCheckedChange),
+ enabled = changeable(),
+ interactionSource = interactionSource,
+ )
+ } else {
+ Switch(
+ checked = false,
+ onCheckedChange = null,
+ enabled = false,
+ interactionSource = interactionSource,
+ )
+ }
+}
diff --git a/spa/src/com/android/settingslib/spa/widget/ui/Text.kt b/spa/src/com/android/settingslib/spa/widget/ui/Text.kt
new file mode 100644
index 00000000..a59b95a6
--- /dev/null
+++ b/spa/src/com/android/settingslib/spa/widget/ui/Text.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settingslib.spa.widget.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsOpacity.alphaForEnabled
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.framework.theme.toMediumWeight
+
+@Composable
+fun SettingsTitle(title: String, useMediumWeight: Boolean = false) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(vertical = SettingsDimension.paddingTiny),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium.withWeight(useMediumWeight),
+ )
+}
+
+@Composable
+fun SettingsTitleSmall(title: String, useMediumWeight: Boolean = false) {
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleSmall.withWeight(useMediumWeight),
+ )
+}
+
+@Composable
+fun SettingsDialogItem(text: String, enabled: Boolean = true) {
+ Text(
+ text = text,
+ modifier = Modifier.alphaForEnabled(enabled),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Composable
+fun SettingsListItem(text: String, enabled: Boolean = true) {
+ Text(
+ text = text,
+ modifier = Modifier
+ .alphaForEnabled(enabled)
+ .padding(vertical = SettingsDimension.paddingTiny),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Composable
+fun SettingsBody(
+ body: String,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ if (body.isNotEmpty()) {
+ Text(
+ text = body,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = maxLines,
+ )
+ }
+}
+
+@Composable
+fun PlaceholderTitle(title: String) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(SettingsDimension.itemPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ }
+}
+
+private fun TextStyle.withWeight(useMediumWeight: Boolean = false) = when (useMediumWeight) {
+ true -> toMediumWeight()
+ else -> this
+}
+
+@Preview
+@Composable
+private fun BasePreferencePreview() {
+ SettingsTheme {
+ Column(Modifier.width(100.dp)) {
+ SettingsTitle(
+ title = "Title",
+ )
+ SettingsBody(
+ body = "Long long long long long long text",
+ )
+ SettingsBody(
+ body = "Long long long long long long text",
+ maxLines = 1,
+ )
+ }
+ }
+}