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() + } +}