diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml
index 650d5fabeb85a..7246ff43970c7 100644
--- a/packages/SystemUI/AndroidManifest.xml
+++ b/packages/SystemUI/AndroidManifest.xml
@@ -344,6 +344,15 @@
+
+
+
+
+
diff --git a/packages/SystemUI/src/com/android/systemui/ChooserPinMigration.kt b/packages/SystemUI/src/com/android/systemui/ChooserPinMigration.kt
new file mode 100644
index 0000000000000..2f03259766c01
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/ChooserPinMigration.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.os.Environment
+import android.os.storage.StorageManager
+import android.util.Log
+import androidx.core.util.Supplier
+import com.android.internal.R
+import com.android.systemui.broadcast.BroadcastSender
+import com.android.systemui.flags.FeatureFlags
+import com.android.systemui.flags.Flags
+import java.io.File
+import javax.inject.Inject
+
+/**
+ * Performs a migration of pinned targets to the unbundled chooser if legacy data exists.
+ *
+ * Sends an explicit broadcast with the contents of the legacy pin preferences. The broadcast is
+ * protected by the RECEIVE_CHOOSER_PIN_MIGRATION permission. This class requires the
+ * ADD_CHOOSER_PINS permission in order to be able to send this broadcast.
+ */
+class ChooserPinMigration
+@Inject
+constructor(
+ private val context: Context,
+ private val featureFlags: FeatureFlags,
+ private val broadcastSender: BroadcastSender,
+ legacyPinPrefsFileSupplier: LegacyPinPrefsFileSupplier,
+) : CoreStartable {
+
+ private val legacyPinPrefsFile = legacyPinPrefsFileSupplier.get()
+ private val chooserComponent =
+ ComponentName.unflattenFromString(
+ context.resources.getString(R.string.config_chooserActivity)
+ )
+
+ override fun start() {
+ if (migrationIsRequired()) {
+ doMigration()
+ }
+ }
+
+ private fun migrationIsRequired(): Boolean {
+ return featureFlags.isEnabled(Flags.CHOOSER_MIGRATION_ENABLED) &&
+ legacyPinPrefsFile.exists() &&
+ chooserComponent?.packageName != null
+ }
+
+ private fun doMigration() {
+ Log.i(TAG, "Beginning migration")
+
+ val legacyPinPrefs = context.getSharedPreferences(legacyPinPrefsFile, MODE_PRIVATE)
+
+ if (legacyPinPrefs.all.isEmpty()) {
+ Log.i(TAG, "No data to migrate, deleting legacy file")
+ } else {
+ sendSharedPreferences(legacyPinPrefs)
+ Log.i(TAG, "Legacy data sent, deleting legacy preferences")
+
+ val legacyPinPrefsEditor = legacyPinPrefs.edit()
+ legacyPinPrefsEditor.clear()
+ if (!legacyPinPrefsEditor.commit()) {
+ Log.e(TAG, "Failed to delete legacy preferences")
+ return
+ }
+ }
+
+ if (!legacyPinPrefsFile.delete()) {
+ Log.e(TAG, "Legacy preferences deleted, but failed to delete legacy preferences file")
+ return
+ }
+
+ Log.i(TAG, "Legacy preference deletion complete")
+ }
+
+ private fun sendSharedPreferences(sharedPreferences: SharedPreferences) {
+ val bundle = Bundle()
+
+ sharedPreferences.all.entries.forEach { (key, value) ->
+ when (value) {
+ is Boolean -> bundle.putBoolean(key, value)
+ else -> Log.e(TAG, "Unsupported preference type for $key: ${value?.javaClass}")
+ }
+ }
+
+ sendBundle(bundle)
+ }
+
+ private fun sendBundle(bundle: Bundle) {
+ val intent =
+ Intent().apply {
+ `package` = chooserComponent?.packageName!!
+ action = BROADCAST_ACTION
+ putExtras(bundle)
+ }
+ broadcastSender.sendBroadcast(intent, BROADCAST_PERMISSION)
+ }
+
+ companion object {
+ private const val TAG = "PinnedShareTargetMigration"
+ private const val BROADCAST_ACTION = "android.intent.action.CHOOSER_PIN_MIGRATION"
+ private const val BROADCAST_PERMISSION = "android.permission.RECEIVE_CHOOSER_PIN_MIGRATION"
+
+ class LegacyPinPrefsFileSupplier @Inject constructor(private val context: Context) :
+ Supplier {
+
+ override fun get(): File {
+ val packageDirectory =
+ Environment.getDataUserCePackageDirectory(
+ StorageManager.UUID_PRIVATE_INTERNAL,
+ context.userId,
+ context.packageName,
+ )
+ val sharedPrefsDirectory = File(packageDirectory, "shared_prefs")
+ return File(sharedPrefsDirectory, "chooser_pin_settings.xml")
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt b/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
index b86d419f540f3..bce87eb57777b 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUICoreStartableModule.kt
@@ -17,6 +17,7 @@
package com.android.systemui.dagger
import com.android.keyguard.KeyguardBiometricLockoutLogger
+import com.android.systemui.ChooserPinMigration
import com.android.systemui.ChooserSelector
import com.android.systemui.CoreStartable
import com.android.systemui.LatencyTester
@@ -76,6 +77,13 @@ abstract class SystemUICoreStartableModule {
@ClassKey(AuthController::class)
abstract fun bindAuthController(service: AuthController): CoreStartable
+ /** Inject into ChooserPinMigration. */
+ @Binds
+ @IntoMap
+ @ClassKey(ChooserPinMigration::class)
+ @PerUser
+ abstract fun bindChooserPinMigration(sysui: ChooserPinMigration): CoreStartable
+
/** Inject into ChooserCoreStartable. */
@Binds
@IntoMap
diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
index 8f3f64fbe50a2..f7b79466b6494 100644
--- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
+++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
@@ -613,6 +613,9 @@ object Flags {
val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW =
releasedFlag(1504, "sharesheet_scrollable_image_preview")
+ // TODO(b/274137694) Tracking Bug
+ val CHOOSER_MIGRATION_ENABLED = unreleasedFlag(1505, "chooser_migration_enabled")
+
// 1700 - clipboard
@JvmField val CLIPBOARD_REMOTE_BEHAVIOR = releasedFlag(1701, "clipboard_remote_behavior")
// TODO(b/267162944): Tracking bug
diff --git a/packages/SystemUI/tests/src/com/android/systemui/ChooserPinMigrationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ChooserPinMigrationTest.kt
new file mode 100644
index 0000000000000..44da5f42aafd2
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/ChooserPinMigrationTest.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.res.Resources
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.broadcast.BroadcastSender
+import com.android.systemui.flags.FakeFeatureFlags
+import com.android.systemui.flags.Flags
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.kotlinArgumentCaptor
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import java.io.File
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyZeroInteractions
+import org.mockito.MockitoAnnotations
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class ChooserPinMigrationTest : SysuiTestCase() {
+
+ private val fakeFeatureFlags = FakeFeatureFlags()
+ private val fakePreferences =
+ mutableMapOf(
+ "TestPinnedPackage/TestPinnedClass" to true,
+ "TestUnpinnedPackage/TestUnpinnedClass" to false,
+ )
+ private val intent = kotlinArgumentCaptor()
+ private val permission = kotlinArgumentCaptor()
+
+ private lateinit var chooserPinMigration: ChooserPinMigration
+
+ @Mock private lateinit var mockContext: Context
+ @Mock private lateinit var mockResources: Resources
+ @Mock
+ private lateinit var mockLegacyPinPrefsFileSupplier:
+ ChooserPinMigration.Companion.LegacyPinPrefsFileSupplier
+ @Mock private lateinit var mockFile: File
+ @Mock private lateinit var mockSharedPreferences: SharedPreferences
+ @Mock private lateinit var mockSharedPreferencesEditor: SharedPreferences.Editor
+ @Mock private lateinit var mockBroadcastSender: BroadcastSender
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+
+ whenever(mockContext.resources).thenReturn(mockResources)
+ whenever(mockContext.getSharedPreferences(any(), anyInt()))
+ .thenReturn(mockSharedPreferences)
+ whenever(mockResources.getString(anyInt())).thenReturn("TestPackage/TestClass")
+ whenever(mockSharedPreferences.all).thenReturn(fakePreferences)
+ whenever(mockSharedPreferences.edit()).thenReturn(mockSharedPreferencesEditor)
+ whenever(mockSharedPreferencesEditor.commit()).thenReturn(true)
+ whenever(mockLegacyPinPrefsFileSupplier.get()).thenReturn(mockFile)
+ whenever(mockFile.exists()).thenReturn(true)
+ whenever(mockFile.delete()).thenReturn(true)
+ fakeFeatureFlags.set(Flags.CHOOSER_MIGRATION_ENABLED, true)
+ }
+
+ @Test
+ fun start_performsMigration() {
+ // Arrange
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verify(mockBroadcastSender).sendBroadcast(intent.capture(), permission.capture())
+ assertThat(intent.value.action).isEqualTo("android.intent.action.CHOOSER_PIN_MIGRATION")
+ assertThat(intent.value.`package`).isEqualTo("TestPackage")
+ assertThat(intent.value.extras?.keySet()).hasSize(2)
+ assertThat(intent.value.hasExtra("TestPinnedPackage/TestPinnedClass")).isTrue()
+ assertThat(intent.value.getBooleanExtra("TestPinnedPackage/TestPinnedClass", false))
+ .isTrue()
+ assertThat(intent.value.hasExtra("TestUnpinnedPackage/TestUnpinnedClass")).isTrue()
+ assertThat(intent.value.getBooleanExtra("TestUnpinnedPackage/TestUnpinnedClass", true))
+ .isFalse()
+ assertThat(permission.value).isEqualTo("android.permission.RECEIVE_CHOOSER_PIN_MIGRATION")
+
+ // Assert
+ verify(mockSharedPreferencesEditor).clear()
+ verify(mockSharedPreferencesEditor).commit()
+
+ // Assert
+ verify(mockFile).delete()
+ }
+
+ @Test
+ fun start_doesNotDeleteLegacyPreferencesFile_whenClearingItFails() {
+ // Arrange
+ whenever(mockSharedPreferencesEditor.commit()).thenReturn(false)
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verify(mockBroadcastSender).sendBroadcast(intent.capture(), permission.capture())
+ assertThat(intent.value.action).isEqualTo("android.intent.action.CHOOSER_PIN_MIGRATION")
+ assertThat(intent.value.`package`).isEqualTo("TestPackage")
+ assertThat(intent.value.extras?.keySet()).hasSize(2)
+ assertThat(intent.value.hasExtra("TestPinnedPackage/TestPinnedClass")).isTrue()
+ assertThat(intent.value.getBooleanExtra("TestPinnedPackage/TestPinnedClass", false))
+ .isTrue()
+ assertThat(intent.value.hasExtra("TestUnpinnedPackage/TestUnpinnedClass")).isTrue()
+ assertThat(intent.value.getBooleanExtra("TestUnpinnedPackage/TestUnpinnedClass", true))
+ .isFalse()
+ assertThat(permission.value).isEqualTo("android.permission.RECEIVE_CHOOSER_PIN_MIGRATION")
+
+ // Assert
+ verify(mockSharedPreferencesEditor).clear()
+ verify(mockSharedPreferencesEditor).commit()
+
+ // Assert
+ verify(mockFile, never()).delete()
+ }
+
+ @Test
+ fun start_OnlyDeletesLegacyPreferencesFile_whenEmpty() {
+ // Arrange
+ whenever(mockSharedPreferences.all).thenReturn(emptyMap())
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verifyZeroInteractions(mockBroadcastSender)
+
+ // Assert
+ verifyZeroInteractions(mockSharedPreferencesEditor)
+
+ // Assert
+ verify(mockFile).delete()
+ }
+
+ @Test
+ fun start_DoesNotDoMigration_whenFlagIsDisabled() {
+ // Arrange
+ fakeFeatureFlags.set(Flags.CHOOSER_MIGRATION_ENABLED, false)
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verifyZeroInteractions(mockBroadcastSender)
+
+ // Assert
+ verifyZeroInteractions(mockSharedPreferencesEditor)
+
+ // Assert
+ verify(mockFile, never()).delete()
+ }
+
+ @Test
+ fun start_DoesNotDoMigration_whenLegacyPreferenceFileNotPresent() {
+ // Arrange
+ whenever(mockFile.exists()).thenReturn(false)
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verifyZeroInteractions(mockBroadcastSender)
+
+ // Assert
+ verifyZeroInteractions(mockSharedPreferencesEditor)
+
+ // Assert
+ verify(mockFile, never()).delete()
+ }
+
+ @Test
+ fun start_DoesNotDoMigration_whenConfiguredChooserComponentIsInvalid() {
+ // Arrange
+ whenever(mockResources.getString(anyInt())).thenReturn("InvalidComponent")
+ chooserPinMigration =
+ ChooserPinMigration(
+ mockContext,
+ fakeFeatureFlags,
+ mockBroadcastSender,
+ mockLegacyPinPrefsFileSupplier,
+ )
+
+ // Act
+ chooserPinMigration.start()
+
+ // Assert
+ verifyZeroInteractions(mockBroadcastSender)
+
+ // Assert
+ verifyZeroInteractions(mockSharedPreferencesEditor)
+
+ // Assert
+ verify(mockFile, never()).delete()
+ }
+}