Refactor SettingsLib to remove redundant resources conditionally
Bug: 320878675
Test: manual
Change-Id: I77b3eb4cc2edbbfa6788e53004e370d49da0c0c0
diff --git a/packages/SettingsLib/AvatarPicker/Android.bp b/packages/SettingsLib/AvatarPicker/Android.bp
new file mode 100644
index 0000000..1d42cd4
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/Android.bp
@@ -0,0 +1,31 @@
+package {
+ // See: http://go/android-license-faq
+ // A large-scale-change added 'default_applicable_licenses' to import
+ // all of the 'license_kinds' from "frameworks_base_license"
+ // to get the below license kinds:
+ // SPDX-license-identifier-Apache-2.0
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_library {
+ name: "SettingsLibAvatarPicker",
+ manifest: "AndroidManifest.xml",
+ use_resource_processor: true,
+ platform_apis: true,
+
+ defaults: [
+ "SettingsLintDefaults",
+ ],
+
+ static_libs: [
+ "SettingsLibSettingsTheme",
+ "setupdesign",
+ "guava",
+ ],
+
+ resource_dirs: ["res"],
+ srcs: [
+ "src/**/*.java",
+ "src/**/*.kt",
+ ],
+}
diff --git a/packages/SettingsLib/AvatarPicker/AndroidManifest.xml b/packages/SettingsLib/AvatarPicker/AndroidManifest.xml
new file mode 100644
index 0000000..73dd35b
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.settingslib.avatarpicker">
+
+ <uses-sdk android:minSdkVersion="23" />
+ <application>
+ <activity
+ android:name=".AvatarPickerActivity"
+ android:theme="@style/SudThemeGlifV2.DayNight"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="com.android.avatarpicker.FULL_SCREEN_ACTIVITY" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/avatar_choose_photo_circled.xml b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_choose_photo_circled.xml
new file mode 100644
index 0000000..27bb87f
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_choose_photo_circled.xml
@@ -0,0 +1,30 @@
+<!--
+ 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.
+ -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item>
+ <shape android:shape="oval">
+ <stroke
+ android:width="2dp"
+ android:color="?android:attr/colorPrimary" />
+ </shape>
+ </item>
+ <item
+ android:bottom="@dimen/avatar_picker_icon_inset"
+ android:drawable="@drawable/ic_avatar_choose_photo"
+ android:left="@dimen/avatar_picker_icon_inset"
+ android:right="@dimen/avatar_picker_icon_inset"
+ android:top="@dimen/avatar_picker_icon_inset" />
+</layer-list>
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/avatar_selector.xml b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_selector.xml
new file mode 100644
index 0000000..1fb521a
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_selector.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:state_selected="true">
+ <shape android:shape="oval">
+ <stroke android:width="@dimen/avatar_picker_padding" android:color="?android:attr/colorPrimary" />
+ </shape>
+ </item>
+</selector>
\ No newline at end of file
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/avatar_take_photo_circled.xml b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_take_photo_circled.xml
new file mode 100644
index 0000000..d678e9b
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/avatar_take_photo_circled.xml
@@ -0,0 +1,30 @@
+<!--
+ 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.
+ -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item>
+ <shape android:shape="oval">
+ <stroke
+ android:width="2dp"
+ android:color="?android:attr/colorPrimary" />
+ </shape>
+ </item>
+ <item
+ android:bottom="@dimen/avatar_picker_icon_inset"
+ android:drawable="@drawable/ic_avatar_take_photo"
+ android:left="@dimen/avatar_picker_icon_inset"
+ android:right="@dimen/avatar_picker_icon_inset"
+ android:top="@dimen/avatar_picker_icon_inset" />
+</layer-list>
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle.xml b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle.xml
new file mode 100644
index 0000000..6421f91
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle.xml
@@ -0,0 +1,24 @@
+<!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M5.85,17.1Q7.125,16.125 8.7,15.562Q10.275,15 12,15Q13.725,15 15.3,15.562Q16.875,16.125 18.15,17.1Q19.025,16.075 19.513,14.775Q20,13.475 20,12Q20,8.675 17.663,6.337Q15.325,4 12,4Q8.675,4 6.338,6.337Q4,8.675 4,12Q4,13.475 4.488,14.775Q4.975,16.075 5.85,17.1ZM12,13Q10.525,13 9.512,11.988Q8.5,10.975 8.5,9.5Q8.5,8.025 9.512,7.012Q10.525,6 12,6Q13.475,6 14.488,7.012Q15.5,8.025 15.5,9.5Q15.5,10.975 14.488,11.988Q13.475,13 12,13ZM12,22Q9.925,22 8.1,21.212Q6.275,20.425 4.925,19.075Q3.575,17.725 2.788,15.9Q2,14.075 2,12Q2,9.925 2.788,8.1Q3.575,6.275 4.925,4.925Q6.275,3.575 8.1,2.787Q9.925,2 12,2Q14.075,2 15.9,2.787Q17.725,3.575 19.075,4.925Q20.425,6.275 21.212,8.1Q22,9.925 22,12Q22,14.075 21.212,15.9Q20.425,17.725 19.075,19.075Q17.725,20.425 15.9,21.212Q14.075,22 12,22ZM12,20Q13.325,20 14.5,19.613Q15.675,19.225 16.65,18.5Q15.675,17.775 14.5,17.387Q13.325,17 12,17Q10.675,17 9.5,17.387Q8.325,17.775 7.35,18.5Q8.325,19.225 9.5,19.613Q10.675,20 12,20ZM12,11Q12.65,11 13.075,10.575Q13.5,10.15 13.5,9.5Q13.5,8.85 13.075,8.425Q12.65,8 12,8Q11.35,8 10.925,8.425Q10.5,8.85 10.5,9.5Q10.5,10.15 10.925,10.575Q11.35,11 12,11ZM12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5Q12,9.5 12,9.5ZM12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Q12,18.5 12,18.5Z" />
+</vector>
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_filled.xml b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_filled.xml
new file mode 100644
index 0000000..645fdf7
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_filled.xml
@@ -0,0 +1,27 @@
+<!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM18.36,16.83c-1.43,-1.74 -4.9,-2.33 -6.36,-2.33s-4.93,0.59 -6.36,2.33A7.95,7.95 0,0 1,4 12c0,-4.41 3.59,-8 8,-8s8,3.59 8,8c0,1.82 -0.62,3.49 -1.64,4.83z" />
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M12,6c-1.94,0 -3.5,1.56 -3.5,3.5S10.06,13 12,13s3.5,-1.56 3.5,-3.5S13.94,6 12,6z" />
+</vector>
diff --git a/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_outline.xml b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_outline.xml
new file mode 100644
index 0000000..a5c1038
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/drawable/ic_account_circle_outline.xml
@@ -0,0 +1,25 @@
+<!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?android:attr/colorPrimary"
+ android:pathData="M5.85,17.1q1.275,-0.975 2.85,-1.538Q10.275,15 12,15q1.725,0 3.3,0.563 1.575,0.562 2.85,1.537 0.875,-1.025 1.363,-2.325Q20,13.475 20,12q0,-3.325 -2.337,-5.662Q15.325,4 12,4T6.338,6.338Q4,8.675 4,12q0,1.475 0.487,2.775 0.488,1.3 1.363,2.325zM12,13q-1.475,0 -2.488,-1.012Q8.5,10.975 8.5,9.5t1.012,-2.487Q10.525,6 12,6t2.488,1.013Q15.5,8.024 15.5,9.5t-1.012,2.488Q13.475,13 12,13zM12,22q-2.075,0 -3.9,-0.788 -1.825,-0.787 -3.175,-2.137 -1.35,-1.35 -2.137,-3.175Q2,14.075 2,12t0.788,-3.9q0.787,-1.825 2.137,-3.175 1.35,-1.35 3.175,-2.137Q9.925,2 12,2t3.9,0.788q1.825,0.787 3.175,2.137 1.35,1.35 2.137,3.175Q22,9.925 22,12t-0.788,3.9q-0.787,1.825 -2.137,3.175 -1.35,1.35 -3.175,2.137Q14.075,22 12,22zM12,20q1.325,0 2.5,-0.387 1.175,-0.388 2.15,-1.113 -0.975,-0.725 -2.15,-1.113Q13.325,17 12,17t-2.5,0.387q-1.175,0.388 -2.15,1.113 0.975,0.725 2.15,1.113Q10.675,20 12,20zM12,11q0.65,0 1.075,-0.425 0.425,-0.425 0.425,-1.075 0,-0.65 -0.425,-1.075Q12.65,8 12,8q-0.65,0 -1.075,0.425Q10.5,8.85 10.5,9.5q0,0.65 0.425,1.075Q11.35,11 12,11zM12,9.5zM12,18.5z" />
+</vector>
+
diff --git a/packages/SettingsLib/AvatarPicker/res/layout/avatar_item.xml b/packages/SettingsLib/AvatarPicker/res/layout/avatar_item.xml
new file mode 100644
index 0000000..cc4e8a7
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/layout/avatar_item.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/avatar_image"
+ android:layout_width="@dimen/avatar_size_in_picker"
+ android:layout_height="@dimen/avatar_size_in_picker"
+ android:layout_gravity="center"
+ android:layout_margin="@dimen/avatar_picker_margin"
+ android:background="@drawable/avatar_selector"
+ android:padding="@dimen/avatar_picker_padding" />
diff --git a/packages/SettingsLib/AvatarPicker/res/layout/avatar_picker.xml b/packages/SettingsLib/AvatarPicker/res/layout/avatar_picker.xml
new file mode 100644
index 0000000..e9d375e
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/layout/avatar_picker.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ 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.
+ -->
+<com.google.android.setupdesign.GlifLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/glif_layout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:icon="@drawable/ic_account_circle_outline"
+ app:sucHeaderText="@string/avatar_picker_title"
+ app:sucUsePartnerResource="true">
+
+ <LinearLayout
+ style="@style/SudContentFrame"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:gravity="center_horizontal">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/avatar_grid"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+ </LinearLayout>
+
+</com.google.android.setupdesign.GlifLayout>
\ No newline at end of file
diff --git a/packages/SettingsLib/AvatarPicker/res/values/arrays.xml b/packages/SettingsLib/AvatarPicker/res/values/arrays.xml
new file mode 100644
index 0000000..042bc41
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/values/arrays.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Copyright 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.
+
+-->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <!-- Images offered as options in the avatar picker. If populated, the avatar_image_descriptions
+ array must also be populated with a content description for each image. -->
+ <array name="avatar_images" />
+
+ <!-- Content descriptions for each of the images in the avatar_images array. When overlaid
+ these values should be translated, but this empty array must not be translated or it may
+ replace the real descriptions with an empty array. -->
+ <string-array name="avatar_image_descriptions" translatable="false" />
+</resources>
\ No newline at end of file
diff --git a/packages/SettingsLib/AvatarPicker/res/values/dimens.xml b/packages/SettingsLib/AvatarPicker/res/values/dimens.xml
new file mode 100644
index 0000000..df54dc2
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/values/dimens.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ -->
+<resources>
+ <dimen name="avatar_size_in_picker">96dp</dimen>
+ <dimen name="avatar_picker_padding">6dp</dimen>
+ <dimen name="avatar_picker_margin">2dp</dimen>
+ <dimen name="avatar_picker_icon_inset">25dp</dimen>
+ <integer name="avatar_picker_columns">3</integer>
+</resources>
\ No newline at end of file
diff --git a/packages/SettingsLib/AvatarPicker/res/values/strings.xml b/packages/SettingsLib/AvatarPicker/res/values/strings.xml
new file mode 100644
index 0000000..1ead128
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/res/values/strings.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Copyright (C) 2024 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License
+ -->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <!-- An option in a photo selection dialog to choose a pre-existing image [CHAR LIMIT=50] -->
+ <string name="user_image_choose_photo">Choose an image</string>
+
+ <!-- An option in a photo selection dialog to take a new photo [CHAR LIMIT=50] -->
+ <string name="user_image_take_photo">Take a photo</string>
+
+ <!-- Title for a screen allowing the user to choose a profile picture. [CHAR LIMIT=NONE] -->
+ <string name="avatar_picker_title">Choose a profile picture</string>
+
+ <!-- Content description for a default user icon. [CHAR LIMIT=NONE] -->
+ <string name="default_user_icon_description">Default user icon</string>
+
+ <!-- Button label for generic Done action, to be pressed when an action has been completed [CHAR LIMIT=20] -->
+ <string name="done">Done</string>
+</resources>
\ No newline at end of file
diff --git a/packages/SettingsLib/AvatarPicker/src/AvatarPhotoController.java b/packages/SettingsLib/AvatarPicker/src/AvatarPhotoController.java
new file mode 100644
index 0000000..c20392a
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/src/AvatarPhotoController.java
@@ -0,0 +1,400 @@
+/*
+ * 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.avatarpicker;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.media.ExifInterface;
+import android.net.Uri;
+import android.os.StrictMode;
+import android.provider.MediaStore;
+import android.util.EventLog;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.FileProvider;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import libcore.io.Streams;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+
+class AvatarPhotoController {
+
+ interface AvatarUi {
+ boolean isFinishing();
+
+ void returnUriResult(Uri uri);
+
+ void startActivityForResult(Intent intent, int resultCode);
+
+ boolean startSystemActivityForResult(Intent intent, int resultCode);
+
+ int getPhotoSize();
+ }
+
+ interface ContextInjector {
+ File getCacheDir();
+
+ Uri createTempImageUri(File parentDir, String fileName, boolean purge);
+
+ ContentResolver getContentResolver();
+
+ Context getContext();
+ }
+
+ private static final String TAG = "AvatarPhotoController";
+
+ static final int REQUEST_CODE_CHOOSE_PHOTO = 1001;
+ static final int REQUEST_CODE_TAKE_PHOTO = 1002;
+ static final int REQUEST_CODE_CROP_PHOTO = 1003;
+
+ /**
+ * Delay to allow the photo picker exit animation to complete before the crop activity opens.
+ */
+ private static final long DELAY_BEFORE_CROP_MILLIS = 150;
+
+ private static final String IMAGES_DIR = "multi_user";
+ private static final String PRE_CROP_PICTURE_FILE_NAME = "PreCropEditUserPhoto.jpg";
+ private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg";
+ private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg";
+
+ private final int mPhotoSize;
+
+ private final AvatarUi mAvatarUi;
+ private final ContextInjector mContextInjector;
+
+ private final File mImagesDir;
+ private final Uri mPreCropPictureUri;
+ private final Uri mCropPictureUri;
+ private final Uri mTakePictureUri;
+
+ AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting) {
+ mAvatarUi = avatarUi;
+ mContextInjector = contextInjector;
+
+ mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
+ mImagesDir.mkdir();
+ mPreCropPictureUri = mContextInjector
+ .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting);
+ mCropPictureUri =
+ mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting);
+ mTakePictureUri =
+ mContextInjector.createTempImageUri(mImagesDir, TAKE_PICTURE_FILE_NAME, !waiting);
+ mPhotoSize = mAvatarUi.getPhotoSize();
+ }
+
+ /**
+ * Handles activity result from containing activity/fragment after a take/choose/crop photo
+ * action result is received.
+ */
+ public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode != Activity.RESULT_OK) {
+ return false;
+ }
+ final Uri pictureUri = data != null && data.getData() != null
+ ? data.getData() : mTakePictureUri;
+
+ // Check if the result is a content uri
+ if (!ContentResolver.SCHEME_CONTENT.equals(pictureUri.getScheme())) {
+ Log.e(TAG, "Invalid pictureUri scheme: " + pictureUri.getScheme());
+ EventLog.writeEvent(0x534e4554, "172939189", -1, pictureUri.getPath());
+ return false;
+ }
+
+ switch (requestCode) {
+ case REQUEST_CODE_CROP_PHOTO:
+ mAvatarUi.returnUriResult(pictureUri);
+ return true;
+ case REQUEST_CODE_TAKE_PHOTO:
+ if (mTakePictureUri.equals(pictureUri)) {
+ cropPhoto(pictureUri);
+ } else {
+ copyAndCropPhoto(pictureUri, false);
+ }
+ return true;
+ case REQUEST_CODE_CHOOSE_PHOTO:
+ copyAndCropPhoto(pictureUri, true);
+ return true;
+ }
+ return false;
+ }
+
+ void takePhoto() {
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE);
+ appendOutputExtra(intent, mTakePictureUri);
+ mAvatarUi.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
+ }
+
+ void choosePhoto() {
+ Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null);
+ intent.setType("image/*");
+ mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO);
+ }
+
+ private void copyAndCropPhoto(final Uri pictureUri, boolean delayBeforeCrop) {
+ ListenableFuture<Uri> future = ThreadUtils.getBackgroundExecutor().submit(() -> {
+ final ContentResolver cr = mContextInjector.getContentResolver();
+ try {
+ InputStream in = cr.openInputStream(pictureUri);
+ OutputStream out = cr.openOutputStream(mPreCropPictureUri);
+ Streams.copy(in, out);
+ return mPreCropPictureUri;
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to copy photo", e);
+ return null;
+ }
+ });
+ Futures.addCallback(future, new FutureCallback<>() {
+ @Override
+ public void onSuccess(@Nullable Uri result) {
+ if (result == null) {
+ return;
+ }
+ Runnable cropRunnable = () -> {
+ if (!mAvatarUi.isFinishing()) {
+ cropPhoto(mPreCropPictureUri);
+ }
+ };
+ if (delayBeforeCrop) {
+ mContextInjector.getContext().getMainThreadHandler()
+ .postDelayed(cropRunnable, DELAY_BEFORE_CROP_MILLIS);
+ } else {
+ cropRunnable.run();
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ Log.e(TAG, "Error performing copy-and-crop", t);
+ }
+ }, mContextInjector.getContext().getMainExecutor());
+ }
+
+ private void cropPhoto(final Uri pictureUri) {
+ // TODO: Use a public intent, when there is one.
+ Intent intent = new Intent("com.android.camera.action.CROP");
+ intent.setDataAndType(pictureUri, "image/*");
+ appendOutputExtra(intent, mCropPictureUri);
+ appendCropExtras(intent);
+ try {
+ StrictMode.disableDeathOnFileUriExposure();
+ if (mAvatarUi.startSystemActivityForResult(intent, REQUEST_CODE_CROP_PHOTO)) {
+ return;
+ }
+ } finally {
+ StrictMode.enableDeathOnFileUriExposure();
+ }
+ onPhotoNotCropped(pictureUri);
+ }
+
+ private void appendOutputExtra(Intent intent, Uri pictureUri) {
+ intent.putExtra(MediaStore.EXTRA_OUTPUT, pictureUri);
+ intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ intent.setClipData(ClipData.newRawUri(MediaStore.EXTRA_OUTPUT, pictureUri));
+ }
+
+ private void appendCropExtras(Intent intent) {
+ intent.putExtra("crop", "true");
+ intent.putExtra("scale", true);
+ intent.putExtra("scaleUpIfNeeded", true);
+ intent.putExtra("aspectX", 1);
+ intent.putExtra("aspectY", 1);
+ intent.putExtra("outputX", mPhotoSize);
+ intent.putExtra("outputY", mPhotoSize);
+ }
+
+ private void onPhotoNotCropped(final Uri data) {
+ ListenableFuture<Bitmap> future = ThreadUtils.getBackgroundExecutor().submit(() -> {
+ // Scale and crop to a square aspect ratio
+ Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize,
+ Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(croppedImage);
+ Bitmap fullImage;
+ try (InputStream imageStream = mContextInjector.getContentResolver()
+ .openInputStream(data)) {
+ fullImage = BitmapFactory.decodeStream(imageStream);
+ }
+ if (fullImage == null) {
+ Log.e(TAG, "Image data could not be decoded");
+ return null;
+ }
+ int rotation = getRotation(data);
+ final int squareSize = Math.min(fullImage.getWidth(),
+ fullImage.getHeight());
+ final int left = (fullImage.getWidth() - squareSize) / 2;
+ final int top = (fullImage.getHeight() - squareSize) / 2;
+
+ Matrix matrix = new Matrix();
+ RectF rectSource = new RectF(left, top,
+ left + squareSize, top + squareSize);
+ RectF rectDest = new RectF(0, 0, mPhotoSize, mPhotoSize);
+ matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER);
+ matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f);
+ canvas.drawBitmap(fullImage, matrix, new Paint());
+ saveBitmapToFile(croppedImage, new File(mImagesDir, CROP_PICTURE_FILE_NAME));
+ return croppedImage;
+ });
+ Futures.addCallback(future, new FutureCallback<>() {
+ @Override
+ public void onSuccess(@Nullable Bitmap result) {
+ if (result != null) {
+ mAvatarUi.returnUriResult(mCropPictureUri);
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ Log.e(TAG, "Error performing internal crop", t);
+ }
+ }, mContextInjector.getContext().getMainExecutor());
+ }
+
+ /**
+ * Reads the image's exif data and determines the rotation degree needed to display the image
+ * in portrait mode.
+ */
+ private int getRotation(Uri selectedImage) {
+ int rotation = -1;
+ try {
+ InputStream imageStream =
+ mContextInjector.getContentResolver().openInputStream(selectedImage);
+ ExifInterface exif = new ExifInterface(imageStream);
+ rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1);
+ } catch (IOException exception) {
+ Log.e(TAG, "Error while getting rotation", exception);
+ }
+
+ switch (rotation) {
+ case ExifInterface.ORIENTATION_ROTATE_90:
+ return 90;
+ case ExifInterface.ORIENTATION_ROTATE_180:
+ return 180;
+ case ExifInterface.ORIENTATION_ROTATE_270:
+ return 270;
+ default:
+ return 0;
+ }
+ }
+
+ private void saveBitmapToFile(Bitmap bitmap, File file) {
+ try {
+ OutputStream os = new FileOutputStream(file);
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
+ os.flush();
+ os.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Cannot create temp file", e);
+ }
+ }
+
+ static class AvatarUiImpl implements AvatarUi {
+ private final AvatarPickerActivity mActivity;
+
+ AvatarUiImpl(AvatarPickerActivity activity) {
+ mActivity = activity;
+ }
+
+ @Override
+ public boolean isFinishing() {
+ return mActivity.isFinishing() || mActivity.isDestroyed();
+ }
+
+ @Override
+ public void returnUriResult(Uri uri) {
+ mActivity.returnUriResult(uri);
+ }
+
+ @Override
+ public void startActivityForResult(Intent intent, int resultCode) {
+ mActivity.startActivityForResult(intent, resultCode);
+ }
+
+ @Override
+ public boolean startSystemActivityForResult(Intent intent, int code) {
+ List<ResolveInfo> resolveInfos = mActivity.getPackageManager()
+ .queryIntentActivities(intent, PackageManager.MATCH_SYSTEM_ONLY);
+ if (resolveInfos.isEmpty()) {
+ Log.w(TAG, "No system package activity could be found for code " + code);
+ return false;
+ }
+ intent.setPackage(resolveInfos.get(0).activityInfo.packageName);
+ mActivity.startActivityForResult(intent, code);
+ return true;
+ }
+
+ @Override
+ public int getPhotoSize() {
+ return mActivity.getResources()
+ .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
+ }
+ }
+
+ static class ContextInjectorImpl implements ContextInjector {
+ private final Context mContext;
+ private final String mFileAuthority;
+
+ ContextInjectorImpl(Context context, String fileAuthority) {
+ mContext = context;
+ mFileAuthority = fileAuthority;
+ }
+
+ @Override
+ public File getCacheDir() {
+ return mContext.getCacheDir();
+ }
+
+ @Override
+ public Uri createTempImageUri(File parentDir, String fileName, boolean purge) {
+ final File fullPath = new File(parentDir, fileName);
+ if (purge) {
+ fullPath.delete();
+ }
+ return FileProvider.getUriForFile(mContext, mFileAuthority, fullPath);
+ }
+
+ @Override
+ public ContentResolver getContentResolver() {
+ return mContext.getContentResolver();
+ }
+
+ @Override
+ public Context getContext() {
+ return mContext;
+ }
+ }
+}
diff --git a/packages/SettingsLib/AvatarPicker/src/AvatarPickerActivity.java b/packages/SettingsLib/AvatarPicker/src/AvatarPickerActivity.java
new file mode 100644
index 0000000..de101b1
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/src/AvatarPickerActivity.java
@@ -0,0 +1,391 @@
+/*
+ * 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.avatarpicker;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.core.graphics.drawable.RoundedBitmapDrawable;
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.internal.util.UserIcons;
+
+import com.google.android.setupcompat.template.FooterBarMixin;
+import com.google.android.setupcompat.template.FooterButton;
+import com.google.android.setupdesign.GlifLayout;
+import com.google.android.setupdesign.util.ThemeHelper;
+import com.google.android.setupdesign.util.ThemeResolver;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Activity to allow the user to choose a user profile picture.
+ *
+ * <p>Options are provided to take a photo or choose a photo using the photo picker. In addition,
+ * preselected avatar images may be provided in the resource array {@code avatar_images}. If
+ * provided, every element of that array must be a bitmap drawable.
+ *
+ * <p>If preselected images are not provided, the default avatar will be shown instead, in a range
+ * of colors.
+ *
+ * <p>This activity should be started with startActivityForResult. If a photo or a preselected image
+ * is selected, a Uri will be returned in the data field of the result intent. If a colored default
+ * avatar is selected, the chosen color will be returned as {@code EXTRA_DEFAULT_ICON_TINT_COLOR}
+ * and the data field will be empty.
+ */
+public class AvatarPickerActivity extends Activity {
+
+ static final String EXTRA_FILE_AUTHORITY = "file_authority";
+ static final String EXTRA_DEFAULT_ICON_TINT_COLOR = "default_icon_tint_color";
+
+ private static final String KEY_AWAITING_RESULT = "awaiting_result";
+ private static final String KEY_SELECTED_POSITION = "selected_position";
+
+ private boolean mWaitingForActivityResult;
+
+ private FooterButton mDoneButton;
+ private AvatarAdapter mAdapter;
+
+ private AvatarPhotoController mAvatarPhotoController;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ boolean dayNightEnabled = ThemeHelper.isSetupWizardDayNightEnabled(this);
+ ThemeResolver themeResolver =
+ new ThemeResolver.Builder(ThemeResolver.getDefault())
+ .setDefaultTheme(ThemeHelper.getSuwDefaultTheme(this))
+ .setUseDayNight(true)
+ .build();
+ int themeResId = themeResolver.resolve("", /* suppressDayNight= */ !dayNightEnabled);
+ setTheme(themeResId);
+ ThemeHelper.trySetDynamicColor(this);
+ setContentView(R.layout.avatar_picker);
+ setUpButtons();
+
+ RecyclerView recyclerView = findViewById(R.id.avatar_grid);
+ mAdapter = new AvatarAdapter();
+ recyclerView.setAdapter(mAdapter);
+ recyclerView.setLayoutManager(new GridLayoutManager(this,
+ getResources().getInteger(R.integer.avatar_picker_columns)));
+
+ restoreState(savedInstanceState);
+
+ mAvatarPhotoController = new AvatarPhotoController(
+ new AvatarPhotoController.AvatarUiImpl(this),
+ new AvatarPhotoController.ContextInjectorImpl(this, getFileAuthority()),
+ mWaitingForActivityResult);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mAdapter.onAdapterResume();
+ }
+
+ private void setUpButtons() {
+ GlifLayout glifLayout = findViewById(R.id.glif_layout);
+ FooterBarMixin mixin = glifLayout.getMixin(FooterBarMixin.class);
+
+ FooterButton secondaryButton =
+ new FooterButton.Builder(this)
+ .setText(getString(android.R.string.cancel))
+ .setListener(view -> cancel())
+ .build();
+
+ mDoneButton =
+ new FooterButton.Builder(this)
+ .setText(getString(R.string.done))
+ .setListener(view -> mAdapter.returnSelectionResult())
+ .build();
+ mDoneButton.setEnabled(false);
+
+ mixin.setSecondaryButton(secondaryButton);
+ mixin.setPrimaryButton(mDoneButton);
+ }
+
+ private String getFileAuthority() {
+ String authority = getIntent().getStringExtra(EXTRA_FILE_AUTHORITY);
+ if (authority == null) {
+ Log.e(this.getClass().getName(), "File authority must be provided");
+ finish();
+ }
+ return authority;
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ mWaitingForActivityResult = false;
+ mAvatarPhotoController.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ outState.putBoolean(KEY_AWAITING_RESULT, mWaitingForActivityResult);
+ outState.putInt(KEY_SELECTED_POSITION, mAdapter.mSelectedPosition);
+ super.onSaveInstanceState(outState);
+ }
+
+ private void restoreState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ mWaitingForActivityResult = savedInstanceState.getBoolean(KEY_AWAITING_RESULT, false);
+ mAdapter.mSelectedPosition =
+ savedInstanceState.getInt(KEY_SELECTED_POSITION, AvatarAdapter.NONE);
+ mDoneButton.setEnabled(mAdapter.mSelectedPosition != AvatarAdapter.NONE);
+ }
+ }
+
+ @Override
+ public void startActivityForResult(Intent intent, int requestCode) {
+ mWaitingForActivityResult = true;
+ super.startActivityForResult(intent, requestCode);
+ }
+
+ void returnUriResult(Uri uri) {
+ Intent resultData = new Intent();
+ resultData.setData(uri);
+ setResult(RESULT_OK, resultData);
+ finish();
+ }
+
+ void returnColorResult(int color) {
+ Intent resultData = new Intent();
+ resultData.putExtra(EXTRA_DEFAULT_ICON_TINT_COLOR, color);
+ setResult(RESULT_OK, resultData);
+ finish();
+ }
+
+ private void cancel() {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+
+ private class AvatarAdapter extends RecyclerView.Adapter<AvatarViewHolder> {
+
+ private static final int NONE = -1;
+
+ private final int mTakePhotoPosition;
+ private final int mChoosePhotoPosition;
+ private final int mPreselectedImageStartPosition;
+
+ private final List<Drawable> mImageDrawables;
+ private final List<String> mImageDescriptions;
+ private final TypedArray mPreselectedImages;
+ private final int[] mUserIconColors;
+ private int mSelectedPosition = NONE;
+
+ private int mLastSelectedPosition = NONE;
+
+ AvatarAdapter() {
+ final boolean canTakePhoto =
+ PhotoCapabilityUtils.canTakePhoto(AvatarPickerActivity.this);
+ final boolean canChoosePhoto =
+ PhotoCapabilityUtils.canChoosePhoto(AvatarPickerActivity.this);
+ mTakePhotoPosition = (canTakePhoto ? 0 : NONE);
+ mChoosePhotoPosition = (canChoosePhoto ? (canTakePhoto ? 1 : 0) : NONE);
+ mPreselectedImageStartPosition = (canTakePhoto ? 1 : 0) + (canChoosePhoto ? 1 : 0);
+
+ mPreselectedImages = getResources().obtainTypedArray(R.array.avatar_images);
+ mUserIconColors = UserIcons.getUserIconColors(getResources());
+ mImageDrawables = buildDrawableList();
+ mImageDescriptions = buildDescriptionsList();
+ }
+
+ @NonNull
+ @Override
+ public AvatarViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int position) {
+ LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
+ View itemView = layoutInflater.inflate(R.layout.avatar_item, parent, false);
+ return new AvatarViewHolder(itemView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull AvatarViewHolder viewHolder, int position) {
+ if (position == mTakePhotoPosition) {
+ viewHolder.setDrawable(getDrawable(R.drawable.avatar_take_photo_circled));
+ viewHolder.setContentDescription(getString(R.string.user_image_take_photo));
+
+ } else if (position == mChoosePhotoPosition) {
+ viewHolder.setDrawable(getDrawable(R.drawable.avatar_choose_photo_circled));
+ viewHolder.setContentDescription(getString(R.string.user_image_choose_photo));
+
+ } else if (position >= mPreselectedImageStartPosition) {
+ int index = indexFromPosition(position);
+ viewHolder.setSelected(position == mSelectedPosition);
+ viewHolder.setDrawable(mImageDrawables.get(index));
+ if (mImageDescriptions != null && index < mImageDescriptions.size()) {
+ viewHolder.setContentDescription(mImageDescriptions.get(index));
+ } else {
+ viewHolder.setContentDescription(getString(
+ R.string.default_user_icon_description));
+ }
+ }
+ viewHolder.setClickListener(view -> onViewHolderSelected(position));
+ }
+
+ private void onViewHolderSelected(int position) {
+ if ((mTakePhotoPosition == position) && (mLastSelectedPosition != position)) {
+ mAvatarPhotoController.takePhoto();
+ } else if ((mChoosePhotoPosition == position) && (mLastSelectedPosition != position)) {
+ mAvatarPhotoController.choosePhoto();
+ } else {
+ if (mSelectedPosition == position) {
+ deselect(position);
+ } else {
+ select(position);
+ }
+ }
+ mLastSelectedPosition = position;
+ }
+
+ public void onAdapterResume() {
+ mLastSelectedPosition = NONE;
+ }
+
+ @Override
+ public int getItemCount() {
+ return mPreselectedImageStartPosition + mImageDrawables.size();
+ }
+
+ private List<Drawable> buildDrawableList() {
+ List<Drawable> result = new ArrayList<>();
+
+ for (int i = 0; i < mPreselectedImages.length(); i++) {
+ Drawable drawable = mPreselectedImages.getDrawable(i);
+ if (drawable instanceof BitmapDrawable) {
+ result.add(circularDrawableFrom((BitmapDrawable) drawable));
+ } else {
+ throw new IllegalStateException("Avatar drawables must be bitmaps");
+ }
+ }
+ if (!result.isEmpty()) {
+ return result;
+ }
+
+ // No preselected images. Use tinted default icon.
+ for (int i = 0; i < mUserIconColors.length; i++) {
+ result.add(UserIcons.getDefaultUserIconInColor(getResources(), mUserIconColors[i]));
+ }
+ return result;
+ }
+
+ private List<String> buildDescriptionsList() {
+ if (mPreselectedImages.length() > 0) {
+ return Arrays.asList(
+ getResources().getStringArray(R.array.avatar_image_descriptions));
+ }
+
+ return null;
+ }
+
+ private Drawable circularDrawableFrom(BitmapDrawable drawable) {
+ Bitmap bitmap = drawable.getBitmap();
+
+ RoundedBitmapDrawable roundedBitmapDrawable =
+ RoundedBitmapDrawableFactory.create(getResources(), bitmap);
+ roundedBitmapDrawable.setCircular(true);
+
+ return roundedBitmapDrawable;
+ }
+
+ private int indexFromPosition(int position) {
+ return position - mPreselectedImageStartPosition;
+ }
+
+ private void select(int position) {
+ final int oldSelection = mSelectedPosition;
+ mSelectedPosition = position;
+ notifyItemChanged(position);
+ if (oldSelection != NONE) {
+ notifyItemChanged(oldSelection);
+ } else {
+ mDoneButton.setEnabled(true);
+ }
+ }
+
+ private void deselect(int position) {
+ mSelectedPosition = NONE;
+ notifyItemChanged(position);
+ mDoneButton.setEnabled(false);
+ }
+
+ private void returnSelectionResult() {
+ int index = indexFromPosition(mSelectedPosition);
+ if (mPreselectedImages.length() > 0) {
+ int resourceId = mPreselectedImages.getResourceId(index, -1);
+ if (resourceId == -1) {
+ throw new IllegalStateException("Preselected avatar images must be resources.");
+ }
+ returnUriResult(uriForResourceId(resourceId));
+ } else {
+ returnColorResult(
+ mUserIconColors[index]);
+ }
+ }
+
+ private Uri uriForResourceId(int resourceId) {
+ return new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(getResources().getResourcePackageName(resourceId))
+ .appendPath(getResources().getResourceTypeName(resourceId))
+ .appendPath(getResources().getResourceEntryName(resourceId))
+ .build();
+ }
+ }
+
+ private static class AvatarViewHolder extends RecyclerView.ViewHolder {
+ private final ImageView mImageView;
+
+ AvatarViewHolder(View view) {
+ super(view);
+ mImageView = view.findViewById(R.id.avatar_image);
+ }
+
+ public void setDrawable(Drawable drawable) {
+ mImageView.setImageDrawable(drawable);
+ }
+
+ public void setContentDescription(String desc) {
+ mImageView.setContentDescription(desc);
+ }
+
+ public void setClickListener(View.OnClickListener listener) {
+ mImageView.setOnClickListener(listener);
+ }
+
+ public void setSelected(boolean selected) {
+ mImageView.setSelected(selected);
+ }
+ }
+}
diff --git a/packages/SettingsLib/AvatarPicker/src/PhotoCapabilityUtils.java b/packages/SettingsLib/AvatarPicker/src/PhotoCapabilityUtils.java
new file mode 100644
index 0000000..43cb0f5
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/src/PhotoCapabilityUtils.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2020 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.avatarpicker;
+
+import android.app.KeyguardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.provider.MediaStore;
+
+/**
+ * Utility class that contains helper methods to determine if the current user has permission and
+ * the device is in a proper state to start an activity for a given action.
+ */
+public class PhotoCapabilityUtils {
+
+ /**
+ * Check if the current user can perform any activity for
+ * android.media.action.IMAGE_CAPTURE action.
+ */
+ public static boolean canTakePhoto(Context context) {
+ return context.getPackageManager().queryIntentActivities(
+ new Intent(MediaStore.ACTION_IMAGE_CAPTURE),
+ PackageManager.MATCH_DEFAULT_ONLY).size() > 0;
+ }
+
+ /**
+ * Check if the current user can perform any activity for
+ * ACTION_PICK_IMAGES action for images.
+ * Returns false if the device is currently locked and
+ * requires a PIN, pattern or password to unlock.
+ */
+ public static boolean canChoosePhoto(Context context) {
+ Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
+ intent.setType("image/*");
+ boolean canPerformActivityForGetImage =
+ context.getPackageManager().queryIntentActivities(intent, 0).size() > 0;
+ // on locked device we can't access the images
+ return canPerformActivityForGetImage && !isDeviceLocked(context);
+ }
+
+ /**
+ * Check if the current user can perform any activity for
+ * com.android.camera.action.CROP action for images.
+ * Returns false if the device is currently locked and
+ * requires a PIN, pattern or password to unlock.
+ */
+ public static boolean canCropPhoto(Context context) {
+ Intent intent = new Intent("com.android.camera.action.CROP");
+ intent.setType("image/*");
+ boolean canPerformActivityForCropping =
+ context.getPackageManager().queryIntentActivities(intent, 0).size() > 0;
+ // on locked device we can't start a cropping activity
+ return canPerformActivityForCropping && !isDeviceLocked(context);
+ }
+
+ private static boolean isDeviceLocked(Context context) {
+ KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class);
+ return keyguardManager == null || keyguardManager.isDeviceLocked();
+ }
+
+}
diff --git a/packages/SettingsLib/AvatarPicker/src/ThreadUtils.java b/packages/SettingsLib/AvatarPicker/src/ThreadUtils.java
new file mode 100644
index 0000000..dc19e66
--- /dev/null
+++ b/packages/SettingsLib/AvatarPicker/src/ThreadUtils.java
@@ -0,0 +1,115 @@
+/*
+ * 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.avatarpicker;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.annotation.NonNull;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+
+// copied from SettinsLib/utils
+public class ThreadUtils {
+
+ private static volatile Thread sMainThread;
+ private static volatile Handler sMainThreadHandler;
+ private static volatile ListeningExecutorService sListeningService;
+
+ /**
+ * Returns true if the current thread is the UI thread.
+ */
+ public static boolean isMainThread() {
+ if (sMainThread == null) {
+ sMainThread = Looper.getMainLooper().getThread();
+ }
+ return Thread.currentThread() == sMainThread;
+ }
+
+ /**
+ * Returns a shared UI thread handler.
+ */
+ @NonNull
+ public static Handler getUiThreadHandler() {
+ if (sMainThreadHandler == null) {
+ sMainThreadHandler = new Handler(Looper.getMainLooper());
+ }
+
+ return sMainThreadHandler;
+ }
+
+ /**
+ * Checks that the current thread is the UI thread. Otherwise throws an exception.
+ */
+ public static void ensureMainThread() {
+ if (!isMainThread()) {
+ throw new RuntimeException("Must be called on the UI thread");
+ }
+ }
+
+ /**
+ * Posts runnable in background using shared background thread pool.
+ *
+ * @return A future of the task that can be monitored for updates or cancelled.
+ */
+ @SuppressWarnings("rawtypes")
+ @NonNull
+ public static ListenableFuture postOnBackgroundThread(@NonNull Runnable runnable) {
+ return getBackgroundExecutor().submit(runnable);
+ }
+
+ /**
+ * Posts callable in background using shared background thread pool.
+ *
+ * @return A future of the task that can be monitored for updates or cancelled.
+ */
+ @NonNull
+ public static <T> ListenableFuture<T> postOnBackgroundThread(@NonNull Callable<T> callable) {
+ return getBackgroundExecutor().submit(callable);
+ }
+
+ /**
+ * Posts the runnable on the main thread.
+ *
+ * @deprecated moving work to the main thread should be done via the main executor provided to
+ * {@link com.google.common.util.concurrent.FutureCallback} via
+ * {@link android.content.Context#getMainExecutor()} or by calling an SDK method such as
+ * {@link android.app.Activity#runOnUiThread(Runnable)} or
+ * {@link android.content.Context#getMainThreadHandler()} where appropriate.
+ */
+ @Deprecated
+ public static void postOnMainThread(@NonNull Runnable runnable) {
+ getUiThreadHandler().post(runnable);
+ }
+
+ /**
+ * Provides a shared {@link ListeningExecutorService} created using a fixed thread pool executor
+ */
+ @NonNull
+ public static synchronized ListeningExecutorService getBackgroundExecutor() {
+ if (sListeningService == null) {
+ sListeningService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(
+ Runtime.getRuntime().availableProcessors()));
+ }
+ return sListeningService;
+ }
+}