Allow copy version & package name in App Info page

Fix: 312095344
Test: manual - on "App info" page
Test: unit test
Change-Id: I0dd7c9a4ccc34375cb3dac62115b736dfcffe76a
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt
index b1e1585..90c7d46 100644
--- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt
@@ -49,6 +49,7 @@
 import com.android.settingslib.spa.gallery.preference.TwoTargetSwitchPreferencePageProvider
 import com.android.settingslib.spa.gallery.scaffold.SearchScaffoldPageProvider
 import com.android.settingslib.spa.gallery.ui.CategoryPageProvider
+import com.android.settingslib.spa.gallery.ui.CopyablePageProvider
 import com.android.settingslib.spa.gallery.ui.SpinnerPageProvider
 import com.android.settingslib.spa.slice.SpaSliceBroadcastReceiver
 
@@ -100,6 +101,7 @@
                 SettingsTextFieldPasswordPageProvider,
                 SearchScaffoldPageProvider,
                 CardPageProvider,
+                CopyablePageProvider,
             ),
             rootPages = listOf(
                 HomePageProvider.createSettingsPage(),
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePageProvider.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePageProvider.kt
index f52ceec..1d897f7 100644
--- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePageProvider.kt
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePageProvider.kt
@@ -44,6 +44,7 @@
 import com.android.settingslib.spa.gallery.preference.PreferenceMainPageProvider
 import com.android.settingslib.spa.gallery.scaffold.SearchScaffoldPageProvider
 import com.android.settingslib.spa.gallery.ui.CategoryPageProvider
+import com.android.settingslib.spa.gallery.ui.CopyablePageProvider
 import com.android.settingslib.spa.gallery.ui.SpinnerPageProvider
 import com.android.settingslib.spa.widget.scaffold.HomeScaffold
 
@@ -71,6 +72,7 @@
             AlertDialogPageProvider.buildInjectEntry().setLink(fromPage = owner).build(),
             EditorMainPageProvider.buildInjectEntry().setLink(fromPage = owner).build(),
             CardPageProvider.buildInjectEntry().setLink(fromPage = owner).build(),
+            CopyablePageProvider.buildInjectEntry().setLink(fromPage = owner).build(),
         )
     }
 
diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CopyablePageProvider.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CopyablePageProvider.kt
new file mode 100644
index 0000000..f897d8c
--- /dev/null
+++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/ui/CopyablePageProvider.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.gallery.ui
+
+import android.os.Bundle
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.android.settingslib.spa.framework.common.EntrySearchData
+import com.android.settingslib.spa.framework.common.SettingsEntryBuilder
+import com.android.settingslib.spa.framework.common.SettingsPageProvider
+import com.android.settingslib.spa.framework.common.createSettingsPage
+import com.android.settingslib.spa.framework.compose.navigator
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+import com.android.settingslib.spa.widget.scaffold.RegularScaffold
+import com.android.settingslib.spa.widget.ui.CopyableBody
+
+private const val TITLE = "Sample Copyable"
+
+object CopyablePageProvider : SettingsPageProvider {
+    override val name = "Copyable"
+
+    private val owner = createSettingsPage()
+
+    fun buildInjectEntry(): SettingsEntryBuilder {
+        return SettingsEntryBuilder.createInject(owner)
+            .setUiLayoutFn {
+                Preference(object : PreferenceModel {
+                    override val title = TITLE
+                    override val onClick = navigator(name)
+                })
+            }
+            .setSearchDataFn { EntrySearchData(title = TITLE) }
+    }
+
+    @Composable
+    override fun Page(arguments: Bundle?) {
+        RegularScaffold(title = TITLE) {
+            Box(modifier = Modifier.padding(SettingsDimension.itemPadding)) {
+                CopyableBody(body = "Copyable body")
+            }
+        }
+    }
+}
diff --git a/packages/SettingsLib/Spa/gradle/libs.versions.toml b/packages/SettingsLib/Spa/gradle/libs.versions.toml
index 905640f..b40e911 100644
--- a/packages/SettingsLib/Spa/gradle/libs.versions.toml
+++ b/packages/SettingsLib/Spa/gradle/libs.versions.toml
@@ -15,7 +15,7 @@
 #
 
 [versions]
-agp = "8.1.3"
+agp = "8.1.4"
 compose-compiler = "1.5.1"
 dexmaker-mockito = "2.28.3"
 jvm = "17"
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/CopyableBody.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/ui/CopyableBody.kt
new file mode 100644
index 0000000..930d0a1
--- /dev/null
+++ b/packages/SettingsLib/Spa/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/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CopyableBodyTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CopyableBodyTest.kt
new file mode 100644
index 0000000..71072a5
--- /dev/null
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ui/CopyableBodyTest.kt
@@ -0,0 +1,83 @@
+/*
+ * 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 android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CopyableBodyTest {
+    @get:Rule
+    val composeTestRule = createComposeRule()
+
+    private val context: Context = ApplicationProvider.getApplicationContext()
+
+    @Test
+    fun text_isDisplayed() {
+        composeTestRule.setContent {
+            CopyableBody(TEXT)
+        }
+
+        composeTestRule.onNodeWithText(TEXT).assertIsDisplayed()
+    }
+
+    @Test
+    fun onLongPress_contextMenuDisplayed() {
+        composeTestRule.setContent {
+            CopyableBody(TEXT)
+        }
+
+        composeTestRule.onNodeWithText(TEXT).performTouchInput {
+            longClick()
+        }
+
+        composeTestRule.onNodeWithText(context.getString(android.R.string.copy)).assertIsDisplayed()
+    }
+
+    @Test
+    fun onCopy_saveToClipboard() {
+        val clipboardManager = context.getSystemService(ClipboardManager::class.java)!!
+        clipboardManager.setPrimaryClip(ClipData.newPlainText("", ""))
+        composeTestRule.setContent {
+            CopyableBody(TEXT)
+        }
+
+        composeTestRule.onNodeWithText(TEXT).performTouchInput {
+            longClick()
+        }
+        composeTestRule.onNodeWithText(context.getString(android.R.string.copy)).performClick()
+
+        assertThat(clipboardManager.primaryClip!!.getItemAt(0).text.toString()).isEqualTo(TEXT)
+    }
+
+    private companion object {
+        const val TEXT = "Text"
+    }
+}
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
index fc10a27..45295b0 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
@@ -36,10 +36,10 @@
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
 import com.android.settingslib.development.DevelopmentSettingsEnabler
 import com.android.settingslib.spa.framework.compose.rememberDrawablePainter
 import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.ui.CopyableBody
 import com.android.settingslib.spa.widget.ui.SettingsBody
 import com.android.settingslib.spa.widget.ui.SettingsTitle
 import com.android.settingslib.spaprivileged.R
@@ -71,26 +71,38 @@
     @Composable
     private fun InstallType(app: ApplicationInfo) {
         if (!app.isInstantApp) return
-        Spacer(modifier = Modifier.height(4.dp))
-        SettingsBody(stringResource(com.android.settingslib.widget.preference.app.R.string.install_type_instant))
+        Spacer(modifier = Modifier.height(SettingsDimension.paddingSmall))
+        SettingsBody(
+            stringResource(
+                com.android.settingslib.widget.preference.app.R.string.install_type_instant
+            )
+        )
     }
 
     @Composable
     private fun AppVersion() {
-        if (packageInfo.versionName == null) return
-        Spacer(modifier = Modifier.height(4.dp))
-        SettingsBody(packageInfo.versionNameBidiWrapped)
+        val versionName = packageInfo.versionNameBidiWrapped ?: return
+        Spacer(modifier = Modifier.height(SettingsDimension.paddingSmall))
+        SettingsBody(versionName)
     }
 
     @Composable
     fun FooterAppVersion(showPackageName: Boolean = rememberIsDevelopmentSettingsEnabled()) {
-        if (packageInfo.versionName == null) return
+        val context = LocalContext.current
+        val footer = remember(showPackageName) {
+            val list = mutableListOf<String>()
+            packageInfo.versionNameBidiWrapped?.let {
+                list += context.getString(R.string.version_text, it)
+            }
+            if (showPackageName) {
+                list += packageInfo.packageName
+            }
+            list.joinToString(separator = System.lineSeparator())
+        }
+        if (footer.isBlank()) return
         HorizontalDivider()
         Column(modifier = Modifier.padding(SettingsDimension.itemPadding)) {
-            SettingsBody(stringResource(R.string.version_text, packageInfo.versionNameBidiWrapped))
-            if (showPackageName) {
-                SettingsBody(packageInfo.packageName)
-            }
+            CopyableBody(footer)
         }
     }
 
@@ -104,7 +116,7 @@
 
     private companion object {
         /** Wrapped the version name, so its directionality still keep same when RTL. */
-        val PackageInfo.versionNameBidiWrapped: String
+        val PackageInfo.versionNameBidiWrapped: String?
             get() = BidiFormatter.getInstance().unicodeWrap(versionName)
     }
 }
diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppInfoTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppInfoTest.kt
index ab34f68..72a5bd7 100644
--- a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppInfoTest.kt
+++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppInfoTest.kt
@@ -105,7 +105,8 @@
             }
         }
 
-        composeTestRule.onNodeWithText("version $VERSION_NAME").assertIsDisplayed()
+        composeTestRule.onNodeWithText(text = "version $VERSION_NAME", substring = true)
+            .assertIsDisplayed()
     }
 
     @Test
@@ -119,10 +120,10 @@
 
         composeTestRule.setContent {
             CompositionLocalProvider(LocalContext provides context) {
-                appInfoProvider.FooterAppVersion(true)
+                appInfoProvider.FooterAppVersion(showPackageName = true)
             }
         }
-        composeTestRule.onNodeWithText(PACKAGE_NAME).assertIsDisplayed()
+        composeTestRule.onNodeWithText(text = PACKAGE_NAME, substring = true).assertIsDisplayed()
     }
 
 
@@ -137,10 +138,10 @@
 
         composeTestRule.setContent {
             CompositionLocalProvider(LocalContext provides context) {
-                appInfoProvider.FooterAppVersion(false)
+                appInfoProvider.FooterAppVersion(showPackageName = false)
             }
         }
-        composeTestRule.onNodeWithText(PACKAGE_NAME).assertDoesNotExist()
+        composeTestRule.onNodeWithText(text = PACKAGE_NAME, substring = true).assertDoesNotExist()
     }
 
     private companion object {