diff --git a/.github/workflows/apply_spotless.yml b/.github/workflows/apply_spotless.yml index 5ba1f6fe5..d69f817b0 100644 --- a/.github/workflows/apply_spotless.yml +++ b/.github/workflows/apply_spotless.yml @@ -42,13 +42,7 @@ jobs: java-version: '17' - name: Run spotlessApply - run: ./gradlew :compose:spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace - - - name: Run spotlessApply for Wear - run: ./gradlew :wear:spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace - - - name: Run spotlessApply for Misc - run: ./gradlew :misc:spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace + run: ./gradlew spotlessApply --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace - name: Auto-commit if spotlessApply has changes uses: stefanzweifel/git-auto-commit-action@v5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 97b36f468..72e815a31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,13 +37,7 @@ jobs: with: distribution: 'zulu' java-version: '17' - - name: Build Compose - run: ./gradlew :compose:snippets:build - - name: Build recompose snippets - run: ./gradlew :compose:recomposehighlighter:build - - name: Build kotlin snippets - run: ./gradlew :kotlin:build - - name: Build Wear snippets - run: ./gradlew :wear:build - - name: Build misc snippets - run: ./gradlew :misc:build + - name: Build All + run: ./gradlew build --stacktrace + - name: Build Watch Face Push validation snippets + run: ./gradlew :watchfacepush:validator:run --stacktrace diff --git a/.gitignore b/.gitignore index 30e5f7bdc..9b10be5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /.idea/modules.xml /.idea/workspace.xml .DS_Store -/build +build /captures .externalNativeBuild .idea/* diff --git a/bluetoothle/src/main/AndroidManifest.xml b/bluetoothle/src/main/AndroidManifest.xml index 1661e746b..d15b213e2 100644 --- a/bluetoothle/src/main/AndroidManifest.xml +++ b/bluetoothle/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -23,6 +22,6 @@ + - \ No newline at end of file diff --git a/bluetoothle/src/main/java/com/sample/android/bluetoothle/java/MainActivity.java b/bluetoothle/src/main/java/com/sample/android/bluetoothle/java/MainActivity.java index b296ce70b..5e25d58e6 100644 --- a/bluetoothle/src/main/java/com/sample/android/bluetoothle/java/MainActivity.java +++ b/bluetoothle/src/main/java/com/sample/android/bluetoothle/java/MainActivity.java @@ -1,13 +1,15 @@ package com.sample.android.bluetoothle.java; -import androidx.appcompat.app.AppCompatActivity; - +import android.Manifest; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothManager; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import androidx.annotation.RequiresPermission; +import androidx.appcompat.app.AppCompatActivity; + import com.sample.android.bluetoothle.R; public class MainActivity extends AppCompatActivity { @@ -19,6 +21,7 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_main); } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private void setUpBLE() { // [START get_bluetooth_adapter] // Initializes Bluetooth adapter. diff --git a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/DeviceScanActivity.kt b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/DeviceScanActivity.kt index fd1662998..585a9e3f5 100644 --- a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/DeviceScanActivity.kt +++ b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/DeviceScanActivity.kt @@ -1,10 +1,28 @@ +/* + * Copyright 2025 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 + * + * https://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.sample.android.bluetoothle.kotlin +import android.Manifest import android.app.ListActivity import android.bluetooth.BluetoothAdapter import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult import android.os.Handler +import androidx.annotation.RequiresPermission import com.sample.android.bluetoothle.java.LeDeviceListAdapter /** @@ -31,6 +49,7 @@ class DeviceScanActivity : ListActivity() { // Stops scanning after 10 seconds. private val SCAN_PERIOD: Long = 10000 + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) private fun scanLeDevice() { if (!mScanning) { // Stops scanning after a pre-defined scan period. handler.postDelayed({ @@ -45,4 +64,4 @@ class DeviceScanActivity : ListActivity() { } } // [END start_and_stop_scan] -} \ No newline at end of file +} diff --git a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/LeDeviceListAdapter.kt b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/LeDeviceListAdapter.kt index c8670e094..42e37c07e 100644 --- a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/LeDeviceListAdapter.kt +++ b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/LeDeviceListAdapter.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 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 + * + * https://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.sample.android.bluetoothle.kotlin import android.bluetooth.BluetoothClass @@ -5,11 +21,11 @@ import android.bluetooth.BluetoothDevice import android.content.Context import android.widget.ArrayAdapter -class LeDeviceListAdapter(context: Context?, layout: Int) - : ArrayAdapter(context!!, layout) { +class LeDeviceListAdapter(context: Context?, layout: Int) : + ArrayAdapter(context!!, layout) { fun addDevice(device: BluetoothDevice?) { // This is where you can add devices to the adapter to // show a list of discovered devices in the UI. } -} \ No newline at end of file +} diff --git a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/MainActivity.kt b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/MainActivity.kt index 5555c720d..95fe7df91 100644 --- a/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/MainActivity.kt +++ b/bluetoothle/src/main/java/com/sample/android/bluetoothle/kotlin/MainActivity.kt @@ -1,10 +1,28 @@ +/* + * Copyright 2025 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 + * + * https://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.sample.android.bluetoothle.kotlin +import android.Manifest import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.annotation.RequiresPermission import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { @@ -15,6 +33,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) } + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) private fun setUpBLE() { // [START get_bluetooth_adapter] // Initializes Bluetooth adapter. diff --git a/buildscripts/toml-updater-config.gradle b/buildscripts/toml-updater-config.gradle index 6441ad0f0..f7c9af0a5 100644 --- a/buildscripts/toml-updater-config.gradle +++ b/buildscripts/toml-updater-config.gradle @@ -19,10 +19,6 @@ versionCatalogUpdate { keep { // keep versions without any library or plugin reference keepUnusedVersions.set(true) - // keep all libraries that aren't used in the project - keepUnusedLibraries.set(true) - // keep all plugins that aren't used in the project - keepUnusedPlugins.set(true) } } diff --git a/compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt b/compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt index fdcbbda30..cc92daed6 100644 --- a/compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt +++ b/compose/recomposehighlighter/src/main/java/com/example/android/compose/recomposehighlighter/RecomposeHighlighter.kt @@ -89,6 +89,11 @@ private class RecomposeHighlighterModifier : Modifier.Node(), DrawModifierNode { override val shouldAutoInvalidate: Boolean = false + override fun onReset() { + totalCompositions = 0 + timerJob?.cancel() + } + override fun onDetach() { timerJob?.cancel() } diff --git a/compose/snippets/build.gradle.kts b/compose/snippets/build.gradle.kts index 8b644b694..c8d728da0 100644 --- a/compose/snippets/build.gradle.kts +++ b/compose/snippets/build.gradle.kts @@ -160,6 +160,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.test.junit4.accessibility) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/compose/snippets/src/androidTest/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt b/compose/snippets/src/androidTest/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt index 724876a87..ee6b51325 100644 --- a/compose/snippets/src/androidTest/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt +++ b/compose/snippets/src/androidTest/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt @@ -16,25 +16,109 @@ package com.example.compose.snippets.accessibility +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks import androidx.compose.ui.test.junit4.createAndroidComposeRule -import com.example.compose.snippets.MyActivity +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.compose.ui.unit.dp +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult +import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator import org.junit.Ignore import org.junit.Rule import org.junit.Test -class AccessibilitySnippetsTest { +class AccessibilityTest { + +// [START android_compose_accessibility_testing_label] @Rule @JvmField - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createAndroidComposeRule() + + @Test + fun noAccessibilityLabel() { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(50.dp, 50.dp) + .background(color = Color.Gray) + .clickable { } + .semantics { + contentDescription = "" + } + ) + } - private val nodeMatcher = SemanticsMatcher("DUMMY") { it.isRoot } + composeTestRule.enableAccessibilityChecks() - @Ignore("Dummy test") + // Any action (such as performClick) will perform accessibility checks too: + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } +// [END android_compose_accessibility_testing_label] +// [START android_compose_accessibility_testing_click] + @Test + fun smallClickTarget() { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(20.dp, 20.dp) + .background(color = Color(0xFFFAFBFC)) + .clickable { } + ) + } + + composeTestRule.enableAccessibilityChecks() + + // Any action (such as performClick) will perform accessibility checks too: + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } +// [END android_compose_accessibility_testing_click] + +// [START android_compose_accessibility_testing_validator] + @Test + fun lowContrastScreen() { + composeTestRule.setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color(0xFFFAFBFC)), + contentAlignment = Alignment.Center + ) { + Text(text = "Hello", color = Color(0xFFB0B1B2)) + } + } + + // Optionally, set AccessibilityValidator manually + val accessibilityValidator = AccessibilityValidator() + .setThrowExceptionFor( + AccessibilityCheckResult.AccessibilityCheckResultType.WARNING + ) + + composeTestRule.enableAccessibilityChecks(accessibilityValidator) + + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } +// [END android_compose_accessibility_testing_validator] + + private val nodeMatcher = SemanticsMatcher(description = "DUMMY") { it.isRoot } + + @Ignore("Dummy test") // [START android_compose_accessibility_testing] @Test fun test() { diff --git a/compose/snippets/src/main/AndroidManifest.xml b/compose/snippets/src/main/AndroidManifest.xml index c9b15e6c5..4ec4d9b70 100644 --- a/compose/snippets/src/main/AndroidManifest.xml +++ b/compose/snippets/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" android:theme="@style/Theme.Snippets"> TooltipExamples() TopComponentsDestination.NavigationDrawerExamples -> NavigationDrawerExamples() TopComponentsDestination.SegmentedButtonExamples -> SegmentedButtonExamples() + TopComponentsDestination.SwipeToDismissBoxExamples -> SwipeToDismissBoxExamples() + TopComponentsDestination.SearchBarExamples -> SearchBarExamples() } } } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt index 3c6be8afc..a83111f71 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/accessibility/AccessibilitySnippets.kt @@ -19,9 +19,11 @@ package com.example.compose.snippets.accessibility import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -34,6 +36,7 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Share import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Checkbox @@ -44,10 +47,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -58,16 +64,29 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.CollectionItemInfo import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.snippets.R @@ -155,7 +174,7 @@ private fun LargeBox() { // [START android_compose_accessibility_click_label] @Composable -private fun ArticleListItem(openArticle: () -> Unit) { +private fun ArticleListItem(openArticle: () -> Unit = {}) { Row( Modifier.clickable( // R.string.action_read_article = "read article" @@ -418,6 +437,376 @@ fun FloatingBox() { } // [END android_compose_accessibility_traversal_fab] +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InteractiveElements( + openArticle: () -> Unit = {}, + addToBookmarks: () -> Unit = {}, +) { +// [START android_compose_accessibility_interactive_clickable] + Row( + // Uses `mergeDescendants = true` under the hood + modifier = Modifier.clickable { openArticle() } + ) { + Icon( + painter = painterResource(R.drawable.ic_logo), + contentDescription = "Open", + ) + Text("Accessibility in Compose") + } +// [END android_compose_accessibility_interactive_clickable] + +// [START android_compose_accessibility_interactive_click_label] + Row( + modifier = Modifier + .clickable(onClickLabel = "Open this article") { + openArticle() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_logo), + contentDescription = "Open" + ) + Text("Accessibility in Compose") + } +// [END android_compose_accessibility_interactive_click_label] + +// [START android_compose_accessibility_interactive_long_click] + Row( + modifier = Modifier + .combinedClickable( + onLongClickLabel = "Bookmark this article", + onLongClick = { addToBookmarks() }, + onClickLabel = "Open this article", + onClick = { openArticle() }, + ) + ) {} +// [END android_compose_accessibility_interactive_long_click] +} + +// [START android_compose_accessibility_interactive_nested_click] +@Composable +private fun ArticleList(openArticle: () -> Unit) { + NestedArticleListItem( + // Clickable is set separately, in a nested layer: + onClickAction = openArticle, + // Semantics are set here: + modifier = Modifier.semantics { + onClick( + label = "Open this article", + action = { + // Not needed here: openArticle() + true + } + ) + } + ) +} +// [END android_compose_accessibility_interactive_nested_click] + +@Composable +private fun NestedArticleListItem( + onClickAction: () -> Unit, + modifier: Modifier = Modifier, +) { +} + +@Composable +private fun Semantics( + removeArticle: () -> Unit, + openArticle: () -> Unit, + addToBookmarks: () -> Unit, +) { + +// [START android_compose_accessibility_semantics_alert_polite] + PopupAlert( + message = "You have a new message", + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + } + ) +// [END android_compose_accessibility_semantics_alert_polite] + +// [START android_compose_accessibility_semantics_alert_assertive] + PopupAlert( + message = "Emergency alert incoming", + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Assertive + } + ) +// [END android_compose_accessibility_semantics_alert_assertive] + + Box() { +// [START android_compose_accessibility_semantics_window] + ShareSheet( + message = "Choose how to share this photo", + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .semantics { paneTitle = "New bottom sheet" } + ) +// [END android_compose_accessibility_semantics_window] + } + +// [START android_compose_accessibility_semantics_error] + Error( + errorText = "Fields cannot be empty", + modifier = Modifier + .semantics { + error("Please add both email and password") + } + ) +// [END android_compose_accessibility_semantics_error] + + val progress by remember { mutableFloatStateOf(0F) } +// [START android_compose_accessibility_semantics_progress] + ProgressInfoBar( + modifier = Modifier + .semantics { + progressBarRangeInfo = + ProgressBarRangeInfo( + current = progress, + range = 0F..1F + ) + } + ) +// [END android_compose_accessibility_semantics_progress] + + val milkyWay = List(10) { it.toString() } +// [START android_compose_accessibility_semantics_long_list] + MilkyWayList( + modifier = Modifier + .semantics { + collectionInfo = CollectionInfo( + rowCount = milkyWay.count(), + columnCount = 1 + ) + } + ) { + milkyWay.forEachIndexed { index, text -> + Text( + text = text, + modifier = Modifier.semantics { + collectionItemInfo = + CollectionItemInfo(index, 0, 0, 0) + } + ) + } + } +// [END android_compose_accessibility_semantics_long_list] + +// [START android_compose_accessibility_semantics_custom_action_swipe] + SwipeToDismissBox( + modifier = Modifier.semantics { + // Represents the swipe to dismiss for accessibility + customActions = listOf( + CustomAccessibilityAction( + label = "Remove article from list", + action = { + removeArticle() + true + } + ) + ) + }, + state = rememberSwipeToDismissBoxState(), + backgroundContent = {} + ) { + ArticleListItem() + } +// [END android_compose_accessibility_semantics_custom_action_swipe] + +// [START android_compose_accessibility_semantics_custom_action_long_list] + ArticleListItemRow( + modifier = Modifier + .semantics { + customActions = listOf( + CustomAccessibilityAction( + label = "Open article", + action = { + openArticle() + true + } + ), + CustomAccessibilityAction( + label = "Add to bookmarks", + action = { + addToBookmarks() + true + } + ), + ) + } + ) { + Article( + modifier = Modifier.clearAndSetSemantics { }, + onClick = openArticle, + ) + BookmarkButton( + modifier = Modifier.clearAndSetSemantics { }, + onClick = addToBookmarks, + ) + } +// [END android_compose_accessibility_semantics_custom_action_long_list] +} + +@Composable +private fun PopupAlert( + message: String, + modifier: Modifier = Modifier, +) { +} + +@Composable +fun ShareSheet( + message: String, + modifier: Modifier = Modifier, +) { +} + +@Composable +private fun Error( + errorText: String, + modifier: Modifier = Modifier, +) { +} + +@Composable +private fun ProgressInfoBar( + modifier: Modifier = Modifier, +) { +} + +@Composable +private fun MilkyWayList( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + content() +} + +@Composable +private fun ArticleListItemRow( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + content() +} + +@Composable +fun Article( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { +} + +@Composable +fun BookmarkButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { +} + +// [START android_compose_accessibility_merging] +@Composable +private fun ArticleListItem( + openArticle: () -> Unit, + addToBookmarks: () -> Unit, +) { + + Row(modifier = Modifier.clickable { openArticle() }) { + // Merges with parent clickable: + Icon( + painter = painterResource(R.drawable.ic_logo), + contentDescription = "Article thumbnail" + ) + ArticleDetails() + + // Defies the merge due to its own clickable: + BookmarkButton(onClick = addToBookmarks) + } +} +// [END android_compose_accessibility_merging] + +@Composable +fun ArticleDetails( + modifier: Modifier = Modifier, +) { +} + +// [START android_compose_accessibility_clearing] +// Developer might intend this to be a toggleable. +// Using `clearAndSetSemantics`, on the Row, a clickable modifier is applied, +// a custom description is set, and a Role is applied. + +@Composable +fun FavoriteToggle() { + val checked = remember { mutableStateOf(true) } + Row( + modifier = Modifier + .toggleable( + value = checked.value, + onValueChange = { checked.value = it } + ) + .clearAndSetSemantics { + stateDescription = if (checked.value) "Favorited" else "Not favorited" + toggleableState = ToggleableState(checked.value) + role = Role.Switch + }, + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null // not needed here + + ) + Text("Favorite?") + } +} +// [END android_compose_accessibility_clearing] + +// [START android_compose_accessibility_hiding] +@Composable +fun WatermarkExample( + watermarkText: String, + content: @Composable () -> Unit, +) { + Box { + WatermarkedContent() + // Mark the watermark as hidden to accessibility services. + WatermarkText( + text = watermarkText, + color = Color.Gray.copy(alpha = 0.5f), + modifier = Modifier + .align(Alignment.BottomEnd) + .semantics { hideFromAccessibility() } + ) + } +} + +@Composable +fun DecorativeExample() { + Text( + modifier = + Modifier.semantics { + hideFromAccessibility() + }, + text = "A dot character that is used to decoratively separate information, like •" + ) +} +// [END android_compose_accessibility_hiding] + +@Composable +private fun WatermarkedContent() { +} + +@Composable +private fun WatermarkText( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { +} + private object ColumnWithFab { // [START android_compose_accessibility_traversal_fab_scaffold] @OptIn(ExperimentalMaterial3Api::class) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleListDetailPaneScaffold.kt b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleListDetailPaneScaffold.kt index b79f7ee71..a6be83b43 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleListDetailPaneScaffold.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleListDetailPaneScaffold.kt @@ -17,7 +17,6 @@ package com.example.compose.snippets.adaptivelayouts import android.os.Parcelable -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -30,33 +29,39 @@ import androidx.compose.material3.Card import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun SampleListDetailPaneScaffoldParts() { +fun SampleNavigableListDetailPaneScaffoldParts() { // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part02] - val navigator = rememberListDetailPaneScaffoldNavigator() - - BackHandler(navigator.canNavigateBack()) { - navigator.navigateBack() - } + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() // [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part02] // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part03] - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + NavigableListDetailPaneScaffold( + navigator = scaffoldNavigator, // [START_EXCLUDE] listPane = {}, detailPane = {}, @@ -65,16 +70,21 @@ fun SampleListDetailPaneScaffoldParts() { // [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part03] // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part04] - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + NavigableListDetailPaneScaffold( + navigator = scaffoldNavigator, listPane = { AnimatedPane { MyList( onItemClick = { item -> // Navigate to the detail pane with the passed item - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, item) - } + scope.launch { + scaffoldNavigator + .navigateTo( + ListDetailPaneScaffoldRole.Detail, + item + ) + } + }, ) } }, @@ -85,16 +95,14 @@ fun SampleListDetailPaneScaffoldParts() { // [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part04] // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_part05] - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - listPane = + NavigableListDetailPaneScaffold( + navigator = scaffoldNavigator, // [START_EXCLUDE] - {}, + listPane = {}, // [END_EXCLUDE] detailPane = { AnimatedPane { - navigator.currentDestination?.content?.let { + scaffoldNavigator.currentDestination?.contentKey?.let { MyDetails(it) } } @@ -106,23 +114,67 @@ fun SampleListDetailPaneScaffoldParts() { @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview @Composable -fun SampleListDetailPaneScaffoldFull() { -// [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_full] - val navigator = rememberListDetailPaneScaffoldNavigator() +fun SampleNavigableListDetailPaneScaffoldFull() { + // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_full] + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() - BackHandler(navigator.canNavigateBack()) { - navigator.navigateBack() - } + NavigableListDetailPaneScaffold( + navigator = scaffoldNavigator, + listPane = { + AnimatedPane { + MyList( + onItemClick = { item -> + // Navigate to the detail pane with the passed item + scope.launch { + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + item + ) + } + }, + ) + } + }, + detailPane = { + AnimatedPane { + // Show the detail pane content if selected item is available + scaffoldNavigator.currentDestination?.contentKey?.let { + MyDetails(it) + } + } + }, + ) + // [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_full] +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun SampleListDetailPaneScaffoldWithPredictiveBackFull() { + // [START android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_with_pb_full] + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val customScaffoldDirective = customPaneScaffoldDirective(currentWindowAdaptiveInfo()) + val scope = rememberCoroutineScope() + + ThreePaneScaffoldPredictiveBackHandler( + navigator = scaffoldNavigator, + backBehavior = BackNavigationBehavior.PopUntilContentChange + ) ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + directive = customScaffoldDirective, + scaffoldState = scaffoldNavigator.scaffoldState, listPane = { AnimatedPane { MyList( onItemClick = { item -> // Navigate to the detail pane with the passed item - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, item) + scope.launch { + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + item + ) + } }, ) } @@ -130,15 +182,35 @@ fun SampleListDetailPaneScaffoldFull() { detailPane = { AnimatedPane { // Show the detail pane content if selected item is available - navigator.currentDestination?.content?.let { + scaffoldNavigator.currentDestination?.contentKey?.let { MyDetails(it) } } }, ) -// [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_full] } +fun customPaneScaffoldDirective(currentWindowAdaptiveInfo: WindowAdaptiveInfo): PaneScaffoldDirective { + val horizontalPartitions = when { + currentWindowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint( + WIDTH_DP_EXPANDED_LOWER_BOUND + ) -> 3 + currentWindowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint( + WIDTH_DP_MEDIUM_LOWER_BOUND + ) -> 2 + else -> 1 + } + + return PaneScaffoldDirective( + maxHorizontalPartitions = horizontalPartitions, + horizontalPartitionSpacerSize = 16.dp, + maxVerticalPartitions = 1, + verticalPartitionSpacerSize = 8.dp, + defaultPanePreferredWidth = 320.dp, + excludedBounds = emptyList() + ) +} +// [END android_compose_adaptivelayouts_sample_list_detail_pane_scaffold_with_pb_full] @Composable fun MyList( onItemClick: (MyItem) -> Unit, diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleNavigationSuiteScaffold.kt b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleNavigationSuiteScaffold.kt index 76006e684..e8d06f783 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleNavigationSuiteScaffold.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleNavigationSuiteScaffold.kt @@ -39,7 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import com.example.compose.snippets.R // [START android_compose_adaptivelayouts_sample_navigation_suite_scaffold_destinations] @@ -159,7 +159,7 @@ fun SampleNavigationSuiteScaffoldCustomType() { // [START android_compose_adaptivelayouts_sample_navigation_suite_scaffold_layout_type] val adaptiveInfo = currentWindowAdaptiveInfo() val customNavSuiteType = with(adaptiveInfo) { - if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) { + if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND)) { NavigationSuiteType.NavigationDrawer } else { NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleSupportingPaneScaffold.kt b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleSupportingPaneScaffold.kt index ad2e0d30f..7132c5e7f 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleSupportingPaneScaffold.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/adaptivelayouts/SampleSupportingPaneScaffold.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright 2025 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. @@ -14,11 +14,9 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3AdaptiveApi::class) - package com.example.compose.snippets.adaptivelayouts -import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Button @@ -28,52 +26,61 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.NavigableSupportingPaneScaffold +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun SampleSupportingPaneScaffoldParts() { +fun SampleNavigableSupportingPaneScaffoldParts() { // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_nav_and_back] - val navigator = rememberSupportingPaneScaffoldNavigator() + val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator() + val scope = rememberCoroutineScope() - BackHandler(navigator.canNavigateBack()) { - navigator.navigateBack() - } // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_nav_and_back] // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_params] - SupportingPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + NavigableSupportingPaneScaffold( + navigator = scaffoldNavigator, mainPane = { /*...*/ }, supportingPane = { /*...*/ }, ) // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_params] } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun SampleSupportingPaneScaffoldFull() { +@Preview +fun SampleNavigableSupportingPaneScaffoldFull() { // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_full] - val navigator = rememberSupportingPaneScaffoldNavigator() + val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator() + val scope = rememberCoroutineScope() - BackHandler(navigator.canNavigateBack()) { - navigator.navigateBack() - } - - SupportingPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + NavigableSupportingPaneScaffold( + navigator = scaffoldNavigator, mainPane = { - AnimatedPane(modifier = Modifier.safeContentPadding()) { - // Main pane content - if (navigator.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden) { + AnimatedPane( + modifier = Modifier + .safeContentPadding() + .background(Color.Red) + ) { + if (scaffoldNavigator.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden) { Button( - modifier = Modifier.wrapContentSize(), + modifier = Modifier + .wrapContentSize(), onClick = { - navigator.navigateTo(SupportingPaneScaffoldRole.Supporting) + scope.launch { + scaffoldNavigator.navigateTo(SupportingPaneScaffoldRole.Supporting) + } } ) { Text("Show supporting pane") @@ -85,22 +92,24 @@ fun SampleSupportingPaneScaffoldFull() { }, supportingPane = { AnimatedPane(modifier = Modifier.safeContentPadding()) { - // Supporting pane content Text("Supporting pane") } - }, + } ) // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_full] } // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_extracted_panes] +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun ThreePaneScaffoldScope.MainPane( +fun ThreePaneScaffoldPaneScope.MainPane( shouldShowSupportingPaneButton: Boolean, onNavigateToSupportingPane: () -> Unit, modifier: Modifier = Modifier, ) { - AnimatedPane(modifier = modifier.safeContentPadding()) { + AnimatedPane( + modifier = modifier.safeContentPadding() + ) { // Main pane content if (shouldShowSupportingPaneButton) { Button(onClick = onNavigateToSupportingPane) { @@ -112,8 +121,9 @@ fun ThreePaneScaffoldScope.MainPane( } } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun ThreePaneScaffoldScope.SupportingPane( +fun ThreePaneScaffoldPaneScope.SupportingPane( modifier: Modifier = Modifier, ) { AnimatedPane(modifier = modifier.safeContentPadding()) { @@ -123,25 +133,56 @@ fun ThreePaneScaffoldScope.SupportingPane( } // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_extracted_panes] +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun SampleSupportingPaneScaffoldSimplified() { -// [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified] - val navigator = rememberSupportingPaneScaffoldNavigator() +fun SampleNavigableSupportingPaneScaffoldSimplified() { + // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified] + val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator() + val scope = rememberCoroutineScope() - BackHandler(navigator.canNavigateBack()) { - navigator.navigateBack() - } + NavigableSupportingPaneScaffold( + navigator = scaffoldNavigator, + mainPane = { + MainPane( + shouldShowSupportingPaneButton = scaffoldNavigator.scaffoldValue.secondary == PaneAdaptedValue.Hidden, + onNavigateToSupportingPane = { + scope.launch { + scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Secondary) + } + } + ) + }, + supportingPane = { SupportingPane() }, + ) + // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified] +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun SampleSupportingPaneScaffoldSimplifiedWithPredictiveBackHandler() { + // [START android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified_with_predictive_back_handler] + val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + + ThreePaneScaffoldPredictiveBackHandler( + navigator = scaffoldNavigator, + backBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + ) SupportingPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, + directive = scaffoldNavigator.scaffoldDirective, + scaffoldState = scaffoldNavigator.scaffoldState, mainPane = { MainPane( - shouldShowSupportingPaneButton = navigator.scaffoldValue.secondary == PaneAdaptedValue.Hidden, - onNavigateToSupportingPane = { navigator.navigateTo(ThreePaneScaffoldRole.Secondary) } + shouldShowSupportingPaneButton = scaffoldNavigator.scaffoldValue.secondary == PaneAdaptedValue.Hidden, + onNavigateToSupportingPane = { + scope.launch { + scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Secondary) + } + } ) }, supportingPane = { SupportingPane() }, ) -// [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified] + // [END android_compose_adaptivelayouts_sample_supporting_pane_scaffold_simplified_with_predictive_back_handler] } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationSnippets.kt index 38a1eb44d..bdb82790d 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationSnippets.kt @@ -26,9 +26,11 @@ import androidx.compose.animation.EnterExitState import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColor import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.AnimationVector2D import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.ExperimentalAnimationSpecApi import androidx.compose.animation.core.ExperimentalTransitionApi import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing @@ -43,11 +45,13 @@ import androidx.compose.animation.core.TwoWayConverter import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.animateRect import androidx.compose.animation.core.animateValueAsState import androidx.compose.animation.core.createChildTransition import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.keyframesWithSpline import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.repeatable @@ -71,11 +75,13 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn @@ -93,25 +99,34 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round import com.example.compose.snippets.R import java.text.BreakIterator import java.text.StringCharacterIterator import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive /* * Copyright 2023 The Android Open Source Project @@ -265,14 +280,15 @@ private fun AnimateAsStateSimple() { // [START android_compose_animations_animate_as_state] var enabled by remember { mutableStateOf(true) } - val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha") + val animatedAlpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha") Box( Modifier .fillMaxSize() - .graphicsLayer(alpha = alpha) + .graphicsLayer { alpha = animatedAlpha } .background(Color.Red) ) // [END android_compose_animations_animate_as_state] + { Button(onClick = { enabled = !enabled }) { Text("Animate me!") } } } @Preview @@ -709,6 +725,101 @@ private fun AnimationSpecKeyframe() { // [END android_compose_animations_spec_keyframe] } +@OptIn(ExperimentalAnimationSpecApi::class) +@Composable +private fun AnimationSpecKeyframeWithSpline() { + // [START android_compose_animation_spec_keyframes_with_spline] + val offset by animateOffsetAsState( + targetValue = Offset(300f, 300f), + animationSpec = keyframesWithSpline { + durationMillis = 6000 + Offset(0f, 0f) at 0 + Offset(150f, 200f) atFraction 0.5f + Offset(0f, 100f) atFraction 0.7f + } + ) + // [END android_compose_animation_spec_keyframes_with_spline] +} + +@OptIn(ExperimentalAnimationSpecApi::class) +@Preview +@Composable +private fun OffsetKeyframeWithSplineDemo() { + val points = remember { mutableStateListOf() } + val offsetAnim = remember { + Animatable( + Offset.Zero, + Offset.VectorConverter + ) + } + val density = LocalDensity.current + + BoxWithConstraints( + Modifier.fillMaxSize().drawBehind { + drawPoints( + points = points, + pointMode = PointMode.Lines, + color = Color.LightGray, + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(30f, 20f)) + ) + } + ) { + val minDimension = minOf(maxWidth, maxHeight) + val size = minDimension / 4 + + val sizePx = with(density) { size.toPx() } + val widthPx = with(density) { maxWidth.toPx() } + val heightPx = with(density) { maxHeight.toPx() } + + val maxXOff = (widthPx - sizePx) / 2f + val maxYOff = heightPx - (sizePx / 2f) + + Box( + Modifier.align(Alignment.TopCenter) + .offset { offsetAnim.value.round() } + .size(size) + .background(Color.Red, RoundedCornerShape(50)) + .onPlaced { points.add(it.boundsInParent().center) } + ) + + LaunchedEffect(Unit) { + delay(1000) + while (isActive) { + offsetAnim.animateTo( + targetValue = Offset.Zero, + animationSpec = + keyframesWithSpline { + durationMillis = 4400 + + // Increasingly approach the halfway point moving from side to side + for (i in 0..4) { + val sign = if (i % 2 == 0) 1 else -1 + Offset( + x = maxXOff * (i.toFloat() / 5f) * sign, + y = (maxYOff) * (i.toFloat() / 5f) + ) atFraction (0.1f * i) + } + + // Halfway point (at bottom of the screen) + Offset(0f, maxYOff) atFraction 0.5f + + // Return with mirrored movement + for (i in 0..4) { + val sign = if (i % 2 == 0) 1 else -1 + Offset( + x = maxXOff * (1f - i.toFloat() / 5f) * sign, + y = (maxYOff) * (1f - i.toFloat() / 5f) + ) atFraction ((0.1f * i) + 0.5f) + } + } + ) + points.clear() + } + } + } +} + @Composable private fun AnimationSpecRepeatable() { // [START android_compose_animations_spec_repeatable] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/Cheese.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/Cheese.kt new file mode 100644 index 000000000..2468bce9f --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/Cheese.kt @@ -0,0 +1,182 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo + +import androidx.annotation.DrawableRes +import com.example.compose.snippets.R + +val CheeseImages = listOf( + R.drawable.cheese_1, + R.drawable.cheese_2, + R.drawable.cheese_3, + R.drawable.cheese_4, + R.drawable.cheese_5 +) + +val CheeseNames = listOf( + "Abbaye de Belloc", "Abbaye du Mont des Cats", "Abertam", "Abondance", "Ackawi", + "Acorn", "Adelost", "Affidelice au Chablis", "Afuega'l Pitu", "Airag", "Airedale", + "Aisy Cendre", "Allgauer Emmentaler", "Alverca", "Ambert", "American Cheese", + "Ami du Chambertin", "Anejo Enchilado", "Anneau du Vic-Bilh", "Anthoriro", "Appenzell", + "Aragon", "Ardi Gasna", "Ardrahan", "Armenian String", "Aromes au Gene de Marc", + "Asadero", "Asiago", "Aubisque Pyrenees", "Autun", "Avaxtskyr", "Baby Swiss", + "Babybel", "Baguette Laonnaise", "Bakers", "Baladi", "Balaton", "Bandal", "Banon", + "Barry's Bay Cheddar", "Basing", "Basket Cheese", "Bath Cheese", "Bavarian Bergkase", + "Baylough", "Beaufort", "Beauvoorde", "Beenleigh Blue", "Beer Cheese", "Bel Paese", + "Bergader", "Bergere Bleue", "Berkswell", "Beyaz Peynir", "Bierkase", "Bishop Kennedy", + "Blarney", "Bleu d'Auvergne", "Bleu de Gex", "Bleu de Laqueuille", + "Bleu de Septmoncel", "Bleu Des Causses", "Blue", "Blue Castello", "Blue Rathgore", + "Blue Vein (Australian)", "Blue Vein Cheeses", "Bocconcini", "Bocconcini (Australian)", + "Boeren Leidenkaas", "Bonchester", "Bosworth", "Bougon", "Boule Du Roves", + "Boulette d'Avesnes", "Boursault", "Boursin", "Bouyssou", "Bra", "Braudostur", + "Breakfast Cheese", "Brebis du Lavort", "Brebis du Lochois", "Brebis du Puyfaucon", + "Bresse Bleu", "Brick", "Brie", "Brie de Meaux", "Brie de Melun", "Brillat-Savarin", + "Brin", "Brin d' Amour", "Brin d'Amour", "Brinza (Burduf Brinza)", + "Briquette de Brebis", "Briquette du Forez", "Broccio", "Broccio Demi-Affine", + "Brousse du Rove", "Bruder Basil", "Brusselae Kaas (Fromage de Bruxelles)", "Bryndza", + "Buchette d'Anjou", "Buffalo", "Burgos", "Butte", "Butterkase", "Button (Innes)", + "Buxton Blue", "Cabecou", "Caboc", "Cabrales", "Cachaille", "Caciocavallo", "Caciotta", + "Caerphilly", "Cairnsmore", "Calenzana", "Cambazola", "Camembert de Normandie", + "Canadian Cheddar", "Canestrato", "Cantal", "Caprice des Dieux", "Capricorn Goat", + "Capriole Banon", "Carre de l'Est", "Casciotta di Urbino", "Cashel Blue", "Castellano", + "Castelleno", "Castelmagno", "Castelo Branco", "Castigliano", "Cathelain", + "Celtic Promise", "Cendre d'Olivet", "Cerney", "Chabichou", "Chabichou du Poitou", + "Chabis de Gatine", "Chaource", "Charolais", "Chaumes", "Cheddar", + "Cheddar Clothbound", "Cheshire", "Chevres", "Chevrotin des Aravis", "Chontaleno", + "Civray", "Coeur de Camembert au Calvados", "Coeur de Chevre", "Colby", "Cold Pack", + "Comte", "Coolea", "Cooleney", "Coquetdale", "Corleggy", "Cornish Pepper", + "Cotherstone", "Cotija", "Cottage Cheese", "Cottage Cheese (Australian)", + "Cougar Gold", "Coulommiers", "Coverdale", "Crayeux de Roncq", "Cream Cheese", + "Cream Havarti", "Crema Agria", "Crema Mexicana", "Creme Fraiche", "Crescenza", + "Croghan", "Crottin de Chavignol", "Crottin du Chavignol", "Crowdie", "Crowley", + "Cuajada", "Curd", "Cure Nantais", "Curworthy", "Cwmtawe Pecorino", + "Cypress Grove Chevre", "Danablu (Danish Blue)", "Danbo", "Danish Fontina", + "Daralagjazsky", "Dauphin", "Delice des Fiouves", "Denhany Dorset Drum", "Derby", + "Dessertnyj Belyj", "Devon Blue", "Devon Garland", "Dolcelatte", "Doolin", + "Doppelrhamstufel", "Dorset Blue Vinney", "Double Gloucester", "Double Worcester", + "Dreux a la Feuille", "Dry Jack", "Duddleswell", "Dunbarra", "Dunlop", "Dunsyre Blue", + "Duroblando", "Durrus", "Dutch Mimolette (Commissiekaas)", "Edam", "Edelpilz", + "Emental Grand Cru", "Emlett", "Emmental", "Epoisses de Bourgogne", "Esbareich", + "Esrom", "Etorki", "Evansdale Farmhouse Brie", "Evora De L'Alentejo", "Exmoor Blue", + "Explorateur", "Feta", "Feta (Australian)", "Figue", "Filetta", "Fin-de-Siecle", + "Finlandia Swiss", "Finn", "Fiore Sardo", "Fleur du Maquis", "Flor de Guia", + "Flower Marie", "Folded", "Folded cheese with mint", "Fondant de Brebis", + "Fontainebleau", "Fontal", "Fontina Val d'Aosta", "Formaggio di capra", "Fougerus", + "Four Herb Gouda", "Fourme d' Ambert", "Fourme de Haute Loire", "Fourme de Montbrison", + "Fresh Jack", "Fresh Mozzarella", "Fresh Ricotta", "Fresh Truffles", "Fribourgeois", + "Friesekaas", "Friesian", "Friesla", "Frinault", "Fromage a Raclette", "Fromage Corse", + "Fromage de Montagne de Savoie", "Fromage Frais", "Fruit Cream Cheese", + "Frying Cheese", "Fynbo", "Gabriel", "Galette du Paludier", "Galette Lyonnaise", + "Galloway Goat's Milk Gems", "Gammelost", "Gaperon a l'Ail", "Garrotxa", "Gastanberra", + "Geitost", "Gippsland Blue", "Gjetost", "Gloucester", "Golden Cross", "Gorgonzola", + "Gornyaltajski", "Gospel Green", "Gouda", "Goutu", "Gowrie", "Grabetto", "Graddost", + "Grafton Village Cheddar", "Grana", "Grana Padano", "Grand Vatel", + "Grataron d' Areches", "Gratte-Paille", "Graviera", "Greuilh", "Greve", + "Gris de Lille", "Gruyere", "Gubbeen", "Guerbigny", "Halloumi", + "Halloumy (Australian)", "Haloumi-Style Cheese", "Harbourne Blue", "Havarti", + "Heidi Gruyere", "Hereford Hop", "Herrgardsost", "Herriot Farmhouse", "Herve", + "Hipi Iti", "Hubbardston Blue Cow", "Hushallsost", "Iberico", "Idaho Goatster", + "Idiazabal", "Il Boschetto al Tartufo", "Ile d'Yeu", "Isle of Mull", "Jarlsberg", + "Jermi Tortes", "Jibneh Arabieh", "Jindi Brie", "Jubilee Blue", "Juustoleipa", + "Kadchgall", "Kaseri", "Kashta", "Kefalotyri", "Kenafa", "Kernhem", "Kervella Affine", + "Kikorangi", "King Island Cape Wickham Brie", "King River Gold", "Klosterkaese", + "Knockalara", "Kugelkase", "L'Aveyronnais", "L'Ecir de l'Aubrac", "La Taupiniere", + "La Vache Qui Rit", "Laguiole", "Lairobell", "Lajta", "Lanark Blue", "Lancashire", + "Langres", "Lappi", "Laruns", "Lavistown", "Le Brin", "Le Fium Orbo", "Le Lacandou", + "Le Roule", "Leafield", "Lebbene", "Leerdammer", "Leicester", "Leyden", "Limburger", + "Lincolnshire Poacher", "Lingot Saint Bousquet d'Orb", "Liptauer", "Little Rydings", + "Livarot", "Llanboidy", "Llanglofan Farmhouse", "Loch Arthur Farmhouse", + "Loddiswell Avondale", "Longhorn", "Lou Palou", "Lou Pevre", "Lyonnais", "Maasdam", + "Macconais", "Mahoe Aged Gouda", "Mahon", "Malvern", "Mamirolle", "Manchego", + "Manouri", "Manur", "Marble Cheddar", "Marbled Cheeses", "Maredsous", "Margotin", + "Maribo", "Maroilles", "Mascares", "Mascarpone", "Mascarpone (Australian)", + "Mascarpone Torta", "Matocq", "Maytag Blue", "Meira", "Menallack Farmhouse", + "Menonita", "Meredith Blue", "Mesost", "Metton (Cancoillotte)", "Meyer Vintage Gouda", + "Mihalic Peynir", "Milleens", "Mimolette", "Mine-Gabhar", "Mini Baby Bells", "Mixte", + "Molbo", "Monastery Cheeses", "Mondseer", "Mont D'or Lyonnais", "Montasio", + "Monterey Jack", "Monterey Jack Dry", "Morbier", "Morbier Cru de Montagne", + "Mothais a la Feuille", "Mozzarella", "Mozzarella (Australian)", + "Mozzarella di Bufala", "Mozzarella Fresh, in water", "Mozzarella Rolls", "Munster", + "Murol", "Mycella", "Myzithra", "Naboulsi", "Nantais", "Neufchatel", + "Neufchatel (Australian)", "Niolo", "Nokkelost", "Northumberland", "Oaxaca", + "Olde York", "Olivet au Foin", "Olivet Bleu", "Olivet Cendre", + "Orkney Extra Mature Cheddar", "Orla", "Oschtjepka", "Ossau Fermier", "Ossau-Iraty", + "Oszczypek", "Oxford Blue", "P'tit Berrichon", "Palet de Babligny", "Paneer", "Panela", + "Pannerone", "Pant ys Gawn", "Parmesan (Parmigiano)", "Parmigiano Reggiano", + "Pas de l'Escalette", "Passendale", "Pasteurized Processed", "Pate de Fromage", + "Patefine Fort", "Pave d'Affinois", "Pave d'Auge", "Pave de Chirac", "Pave du Berry", + "Pecorino", "Pecorino in Walnut Leaves", "Pecorino Romano", "Peekskill Pyramid", + "Pelardon des Cevennes", "Pelardon des Corbieres", "Penamellera", "Penbryn", + "Pencarreg", "Perail de Brebis", "Petit Morin", "Petit Pardou", "Petit-Suisse", + "Picodon de Chevre", "Picos de Europa", "Piora", "Pithtviers au Foin", + "Plateau de Herve", "Plymouth Cheese", "Podhalanski", "Poivre d'Ane", "Polkolbin", + "Pont l'Eveque", "Port Nicholson", "Port-Salut", "Postel", "Pouligny-Saint-Pierre", + "Pourly", "Prastost", "Pressato", "Prince-Jean", "Processed Cheddar", "Provolone", + "Provolone (Australian)", "Pyengana Cheddar", "Pyramide", "Quark", + "Quark (Australian)", "Quartirolo Lombardo", "Quatre-Vents", "Quercy Petit", + "Queso Blanco", "Queso Blanco con Frutas --Pina y Mango", "Queso de Murcia", + "Queso del Montsec", "Queso del Tietar", "Queso Fresco", "Queso Fresco (Adobera)", + "Queso Iberico", "Queso Jalapeno", "Queso Majorero", "Queso Media Luna", + "Queso Para Frier", "Queso Quesadilla", "Rabacal", "Raclette", "Ragusano", "Raschera", + "Reblochon", "Red Leicester", "Regal de la Dombes", "Reggianito", "Remedou", + "Requeson", "Richelieu", "Ricotta", "Ricotta (Australian)", "Ricotta Salata", "Ridder", + "Rigotte", "Rocamadour", "Rollot", "Romano", "Romans Part Dieu", "Roncal", "Roquefort", + "Roule", "Rouleau De Beaulieu", "Royalp Tilsit", "Rubens", "Rustinu", "Saaland Pfarr", + "Saanenkaese", "Saga", "Sage Derby", "Sainte Maure", "Saint-Marcellin", + "Saint-Nectaire", "Saint-Paulin", "Salers", "Samso", "San Simon", "Sancerre", + "Sap Sago", "Sardo", "Sardo Egyptian", "Sbrinz", "Scamorza", "Schabzieger", "Schloss", + "Selles sur Cher", "Selva", "Serat", "Seriously Strong Cheddar", "Serra da Estrela", + "Sharpam", "Shelburne Cheddar", "Shropshire Blue", "Siraz", "Sirene", "Smoked Gouda", + "Somerset Brie", "Sonoma Jack", "Sottocenare al Tartufo", "Soumaintrain", + "Sourire Lozerien", "Spenwood", "Sraffordshire Organic", "St. Agur Blue Cheese", + "Stilton", "Stinking Bishop", "String", "Sussex Slipcote", "Sveciaost", "Swaledale", + "Sweet Style Swiss", "Swiss", "Syrian (Armenian String)", "Tala", "Taleggio", "Tamie", + "Tasmania Highland Chevre Log", "Taupiniere", "Teifi", "Telemea", "Testouri", + "Tete de Moine", "Tetilla", "Texas Goat Cheese", "Tibet", "Tillamook Cheddar", + "Tilsit", "Timboon Brie", "Toma", "Tomme Brulee", "Tomme d'Abondance", + "Tomme de Chevre", "Tomme de Romans", "Tomme de Savoie", "Tomme des Chouans", "Tommes", + "Torta del Casar", "Toscanello", "Touree de L'Aubier", "Tourmalet", + "Trappe (Veritable)", "Trois Cornes De Vendee", "Tronchon", "Trou du Cru", "Truffe", + "Tupi", "Turunmaa", "Tymsboro", "Tyn Grug", "Tyning", "Ubriaco", "Ulloa", + "Vacherin-Fribourgeois", "Valencay", "Vasterbottenost", "Venaco", "Vendomois", + "Vieux Corse", "Vignotte", "Vulscombe", "Waimata Farmhouse Blue", + "Washed Rind Cheese (Australian)", "Waterloo", "Weichkaese", "Wellington", + "Wensleydale", "White Stilton", "Whitestone Farmhouse", "Wigmore", "Woodside Cabecou", + "Xanadu", "Xynotyro", "Yarg Cornish", "Yarra Valley Pyramid", "Yorkshire Blue", + "Zamorano", "Zanetti Grana Padano", "Zanetti Parmigiano Reggiano" +) + +data class Cheese( + val id: Long, + val name: String, + @DrawableRes val image: Int +) { + companion object { + fun all(): List { + return CheeseNames.mapIndexed { i, name -> + Cheese( + id = (i + 1).toLong(), + name = name, + image = CheeseImages[ + ((name.hashCode() % CheeseImages.size) + CheeseImages.size) % + CheeseImages.size + ] + ) + } + } + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/SimpleScaffold.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/SimpleScaffold.kt new file mode 100644 index 000000000..987d545a3 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/SimpleScaffold.kt @@ -0,0 +1,58 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleScaffold( + title: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = title) + }, + modifier = Modifier + .statusBarsPadding() + ) + }, + modifier = modifier + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + content() + } + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/custom/CustomCanvasAnimation.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/custom/CustomCanvasAnimation.kt new file mode 100644 index 000000000..e82794346 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/custom/CustomCanvasAnimation.kt @@ -0,0 +1,110 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo.custom + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** + * A custom loading animation example using Canvas and draw APIs, combined with + * Animatable to show the use of the animateTo() function used sequentially. + */ +@Composable +@Preview +fun CustomCanvasBouncyLoader() { + val yOffset = remember { + Animatable(0f) + } + val scale = remember { + Animatable(1f) + } + LaunchedEffect("bouncyLoader") { + delay(400) + // We use the Animatable.animateTo() API here to demonstrate the coroutine usage - each + // item is animating one after the other, as the animateTo function is sequential. + // Animate y to half the height + yOffset.animateTo(0.5f, bouncyAnimationSpec) + scale.animateTo(3f, bouncyAnimationSpec) + delay(500) + scale.animateTo(10f, bouncyAnimationSpec) + delay(500) + scale.animateTo(50f, bouncyAnimationSpec) + } + val size = remember { + mutableStateOf(IntSize.Zero) + } + Box( + Modifier + .fillMaxSize() + .onSizeChanged { + // We get the size change of the whole composable, and use this to determine how + // big the ball should be. + size.value = it + } + ) { + GradientCircle( + Modifier + .align(Alignment.TopCenter) + .size(26.dp) + .graphicsLayer { + // We use .graphicsLayer here to perform the animation as we are animating + // multiple properties of our Gradient circle at once, and this is more + // efficient than using multiple modifiers. + // .graphicsLayer also defers these changes to the Draw phase of Compose, + // therefore minimizing recompositions required to do this. + scaleX = scale.value + scaleY = scale.value + translationY = yOffset.value * size.value.height + } + ) + } +} + +@Composable +private fun GradientCircle(modifier: Modifier = Modifier) { + val brush = remember { + Brush.verticalGradient(listOf(Color(0xFFF56E34), Color(0xFF234EDA))) + } + Canvas(modifier = modifier) { + drawCircle(brush = brush) + } +} + +private val bouncyAnimationSpec: SpringSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow +) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fade/FadeDemo.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fade/FadeDemo.kt new file mode 100644 index 000000000..c46735f25 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fade/FadeDemo.kt @@ -0,0 +1,107 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo.fade + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose.snippets.animations.demo.CheeseImages +import com.example.compose.snippets.animations.demo.SimpleScaffold + +/** + * A fade creates a smooth sequence between elements that fully overlap each other, such as + * photos inside of a card or another container. When a new element enters, it fades in + * over the current element. + */ +@Preview +@Composable +fun FadeDemo() { + SimpleScaffold(title = "Fade") { + + val painters = CheeseImages.map { painterResource(it) } + var index by remember { mutableIntStateOf(0) } + + AnimatedContent( + targetState = index, + modifier = Modifier.align(Alignment.Center), + transitionSpec = fade() + ) { targetIndex -> + Image( + painter = painters[targetIndex], + contentDescription = "Cheese", + modifier = Modifier + .size(256.dp, 192.dp) + .clip(shape = RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + } + + Button( + onClick = { index = (index + 1) % painters.size }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(64.dp) + ) { + Text(text = "NEXT") + } + } +} + +/** + * Creates a transitionSpec for configuring [AnimatedContent] to the fade pattern. + */ +private fun fade( + durationMillis: Int = 300 +): AnimatedContentTransitionScope.() -> ContentTransform { + return { + ContentTransform( + // The initial content should stay until the target content is completely opaque. + initialContentExit = fadeOut(animationSpec = snap(delayMillis = durationMillis)), + // The target content fades in. This is shown on top of the initial content. + targetContentEnter = fadeIn( + animationSpec = tween( + durationMillis = durationMillis, + // LinearOutSlowInEasing is suitable for incoming elements. + easing = LinearOutSlowInEasing + ) + ) + ) + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fadethrough/FadeThroughDemo.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fadethrough/FadeThroughDemo.kt new file mode 100644 index 000000000..efdd35745 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/fadethrough/FadeThroughDemo.kt @@ -0,0 +1,256 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo.fadethrough + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose.snippets.R +import com.example.compose.snippets.animations.demo.SimpleScaffold + +/** + * Fade through involves one element fading out completely before a new one fades in. These + * transitions can be applied to text, icons, and other elements that don't perfectly + * overlap. This technique lets the background show through during a transition, and it can + * provide continuity between screens when paired with a shared transformation. + */ +@Preview +@Composable +fun FadeThroughDemo() { + SimpleScaffold(title = "Fade through") { + var expanded by rememberSaveable { mutableStateOf(false) } + DemoCard( + expanded = expanded, + modifier = Modifier.align(Alignment.Center) + ) + + Button( + onClick = { expanded = !expanded }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(64.dp) + ) { + Text(text = "TOGGLE") + } + } +} + +/** + * Shows the card. The card can be either expanded or collapsed, and the transition between the two + * states is animated with the fade-through effect. + * + * @param expanded Whether the card is expanded or collapsed. + */ +@Composable +private fun DemoCard( + expanded: Boolean, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + tonalElevation = 2.dp, + shape = RoundedCornerShape(16.dp) + ) { + // Use `AnimatedContent` to switch between different content. + AnimatedContent( + // `targetState` specifies the input state. + targetState = expanded, + // `transitionSpec` defines the behavior of the transition animation. + transitionSpec = fadeThrough() + ) { targetExpanded -> + if (targetExpanded) { + ExpandedContent() + } else { + CollapsedContent() + } + } + } +} + +/** + * Creates a transitionSpec for configuring [AnimatedContent] to the fade through pattern. + * See [Fade through](https://material.io/design/motion/the-motion-system.html#fade-through) for + * the motion spec. + */ +fun fadeThrough( + durationMillis: Int = 300 +): AnimatedContentTransitionScope.() -> ContentTransform { + return { + ContentTransform( + // The initial content fades out. + initialContentExit = fadeOut( + animationSpec = tween( + // The duration is 3/8 of the overall duration. + durationMillis = durationMillis * 3 / 8, + // FastOutLinearInEasing is suitable for elements exiting the screen. + easing = FastOutLinearInEasing + ) + ), + // The target content fades in after the current content fades out. + targetContentEnter = fadeIn( + animationSpec = tween( + // The duration is 5/8 of the overall duration. + durationMillis = durationMillis * 5 / 8, + // Delays the EnterTransition by the duration of the ExitTransition. + delayMillis = durationMillis * 3 / 8, + // LinearOutSlowInEasing is suitable for incoming elements. + easing = LinearOutSlowInEasing + ) + ), + // The size changes along with the content transition. + sizeTransform = SizeTransform( + sizeAnimationSpec = { _, _ -> + tween(durationMillis = durationMillis) + } + ) + ) + } +} + +@Composable +private fun CollapsedContent() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Image( + painter = painterResource(R.drawable.cheese_1), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + ) + Text( + text = "Cheese", + style = MaterialTheme.typography.headlineSmall + ) + } +} + +@Composable +private fun ExpandedContent() { + Column( + modifier = Modifier.width(IntrinsicSize.Min) + ) { + Image( + painter = painterResource(R.drawable.cheese_1), + contentDescription = null, + modifier = Modifier.size(320.dp, 128.dp), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Cheese", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Text( + text = "Hello, world", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.lorem_ipsum), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + TextButton(onClick = { /* Do nothing */ }) { + Text(text = "DETAIL") + } + TextButton(onClick = { /* Do nothing */ }) { + Text(text = "ORDER") + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { /* Do nothing */ }) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = "Favorite" + ) + } + IconButton(onClick = { /* Do nothing */ }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview +@Composable +private fun PreviewDemoCardCollapsed() { + DemoCard(expanded = false) +} + +@Preview +@Composable +private fun PreviewDemoCardExpanded() { + DemoCard(expanded = true) +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedaxis/SharedAxisDemo.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedaxis/SharedAxisDemo.kt new file mode 100644 index 000000000..ceb7b4a61 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedaxis/SharedAxisDemo.kt @@ -0,0 +1,282 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo.sharedaxis + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.compose.snippets.animations.demo.CheeseImages +import com.example.compose.snippets.animations.demo.CheeseNames +import com.example.compose.snippets.animations.demo.SimpleScaffold + +/** + * The shared axis pattern is used for transitions between UI elements that have a spatial + * or navigational relationship. This demo uses a shared transformation on the Y-axis to + * reinforce the sequential order of elements. + */ +@Composable +fun SharedAxisDemo() { + SimpleScaffold(title = "Layout > Shared axis (Y-axis)") { + val pages = remember { createPages() } + // Indicator column + var id by rememberSaveable { mutableIntStateOf(1) } + Row(modifier = Modifier.padding(end = 16.dp)) { + PageIndicatorsColumn( + pages = pages, + selectedId = id, + onIndicatorClick = { id = it } + ) + + // SharedYAxis animates the content change. + SharedYAxis(targetState = pages.first { it.id == id }) { page -> + PageContent(page = page) + } + } + } +} + +/** + * Animates content change with the vertical shared axis pattern. + * + * See [Shared axis](https://material.io/design/motion/the-motion-system.html#shared-axis) for the + * detail about this motion pattern. + */ +@Composable +private fun > SharedYAxis( + targetState: T, + modifier: Modifier = Modifier, + content: @Composable AnimatedVisibilityScope.(T) -> Unit +) { + val exitDurationMillis = 80 + val enterDurationMillis = 220 + + // This local function creates the AnimationSpec for outgoing elements. + fun exitSpec(): FiniteAnimationSpec = + tween( + durationMillis = exitDurationMillis, + easing = FastOutLinearInEasing + ) + + // This local function creates the AnimationSpec for incoming elements. + fun enterSpec(): FiniteAnimationSpec = + tween( + // The enter animation runs right after the exit animation. + delayMillis = exitDurationMillis, + durationMillis = enterDurationMillis, + easing = LinearOutSlowInEasing + ) + + val slideDistance = with(LocalDensity.current) { 30.dp.roundToPx() } + + AnimatedContent( + targetState = targetState, + transitionSpec = { + // The state type () is Comparable. + // We compare the initial state and the target state to determine whether we are moving + // down or up. + if (initialState < targetState) { // Move down + ContentTransform( + // Outgoing elements fade out and slide up to the top. + initialContentExit = fadeOut(exitSpec()) + + slideOutVertically(exitSpec()) { -slideDistance }, + // Incoming elements fade in and slide up from the bottom. + targetContentEnter = fadeIn(enterSpec()) + + slideInVertically(enterSpec()) { slideDistance } + ) + } else { // Move up + ContentTransform( + // Outgoing elements fade out and slide down to the bottom. + initialContentExit = fadeOut(exitSpec()) + + slideOutVertically(exitSpec()) { slideDistance }, + // Outgoing elements fade in and slide down from the top. + targetContentEnter = fadeIn(enterSpec()) + + slideInVertically(enterSpec()) { -slideDistance } + ) + } + }, + modifier = modifier, + content = content + ) +} + +private class Page( + val id: Int, + @DrawableRes + val image: Int, + val title: String, + val body: String +) : Comparable { + + override fun compareTo(other: Page): Int { + return id.compareTo(other.id) + } +} + +private fun createPages(): List { + val body = LoremIpsum().values.joinToString(separator = " ").replace('\n', ' ') + return (0..4).map { i -> + Page( + id = i + 1, + image = CheeseImages[i % CheeseImages.size], + title = CheeseNames[i * 128], + body = body + ) + } +} + +@Composable +private fun PageIndicatorsColumn( + pages: List, + selectedId: Int, + onIndicatorClick: (index: Int) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + for (page in pages) { + PageIndicator( + index = page.id, + selected = selectedId == page.id, + onClick = { onIndicatorClick(page.id) } + ) + } + } +} + +@Composable +private fun PageIndicator( + index: Int, + selected: Boolean, + onClick: () -> Unit +) { + val transition = updateTransition(targetState = selected, label = "indicator") + val backgroundColor by transition.animateColor(label = "background color") { targetSelected -> + if (targetSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + } + val textColor by transition.animateColor(label = "text color") { targetSelected -> + if (targetSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + } + IconButton(onClick = onClick) { + Text( + text = index.toString(), + modifier = Modifier + .size(32.dp) + .background(backgroundColor, CircleShape) + .wrapContentSize(), + color = textColor, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun PageContent( + page: Page, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Image( + painter = painterResource(page.image), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + Text( + text = page.title, + style = MaterialTheme.typography.titleLarge + ) + Text( + text = page.body, + textAlign = TextAlign.Justify + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewSharedAxisDemo() { + SharedAxisDemo() +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedtransform/SharedTransformDemo.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedtransform/SharedTransformDemo.kt new file mode 100644 index 000000000..3e913c782 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/demo/sharedtransform/SharedTransformDemo.kt @@ -0,0 +1,261 @@ +/* + * 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 + * + * https://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.example.compose.snippets.animations.demo.sharedtransform + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.example.compose.snippets.R +import com.example.compose.snippets.animations.demo.SimpleScaffold +import com.example.compose.snippets.animations.demo.fadethrough.fadeThrough +import com.example.compose.snippets.ui.theme.SnippetsTheme + +/** + * Complex layout changes use a shared transformation to create smooth transitions from + * one layout to the next. Elements are grouped together and transform as a single unit, + * rather than animating independently. This avoids multiple transformations overlapping + * and competing for attention. + */ +@Composable +fun SharedTransformDemo() { + SimpleScaffold(title = "Shared transform") { + DemoCard( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp) + .widthIn(max = 384.dp) + .fillMaxWidth() + ) + } +} + +@Composable +private fun DemoCard( + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + tonalElevation = 2.dp, + shape = RoundedCornerShape(16.dp), + ) { + // The content of this card is laid out by this ConstraintLayout. + ConstraintLayout { + // The card is either expanded or collapsed. + var expanded by rememberSaveable { mutableStateOf(false) } + + // The ConstraintLayout has 4 constrained elements. They animate separately during the + // animation, except for the icon that is shared in both the expanded and the + // collapsed states. + val (content, icon, divider, button) = createRefs() + + // This transition object coordinates different kinds of animations. + val transition = updateTransition(targetState = expanded, label = "card") + + // This is the main content of the card. + // By using the AnimatedContent composable as an extension function of the transition + // object, the animation runs in sync with other animations of the transition. + // The height of this element animates on the state change (SizeTransform), and the + // ConstraintLayout can lay out its children based on the constraints continuously + // during the animation. + transition.AnimatedContent( + // We use the fade-through effect for elements that change between the states. + transitionSpec = fadeThrough(), + modifier = Modifier.constrainAs(content) { + top.linkTo(parent.top, margin = 16.dp) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + ) { targetExpanded -> + CardContent(expanded = targetExpanded) + } + + // The icon is shared between the expanded and collapsed states. + CardIcon( + modifier = Modifier.constrainAs(icon) { + top.linkTo(parent.top, margin = 16.dp) + end.linkTo(parent.end, margin = 16.dp) + } + ) + + // The divider becomes transparent in the collapsed state. + val dividerColor by transition.animateColor(label = "divider color") { targetExpanded -> + if (targetExpanded) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + } else { + Color.Transparent + } + } + HorizontalDivider( + modifier = Modifier.constrainAs(divider) { + top.linkTo(content.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + color = dividerColor + ) + + // The expand/collapse button is shared between the expanded and collapsed states. + TextButton( + onClick = { expanded = !expanded }, + modifier = Modifier.constrainAs(button) { + top.linkTo(divider.bottom, margin = 8.dp) + start.linkTo(parent.start, margin = 8.dp) + // The button is constrained to the bottom of the parent so that it remains + // visible during the animations. + bottom.linkTo(parent.bottom, margin = 8.dp) + } + ) { + // The AnimatedContent extension function can be used for any descendant elements, + // not just direct children. + transition.AnimatedContent(transitionSpec = fadeThrough()) { targetExpanded -> + Text(text = if (targetExpanded) "COLLAPSE" else "EXPAND") + } + } + } + } +} + +private val CheeseImages = listOf( + R.drawable.cheese_1 to "Cheese 1", + R.drawable.cheese_2 to "Cheese 2", + R.drawable.cheese_3 to "Cheese 3", + R.drawable.cheese_4 to "Cheese 4", + R.drawable.cheese_5 to "Cheese 5" +) + +@Composable +private fun CardContent(expanded: Boolean, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + if (expanded) { + ContentTitle(modifier = Modifier.padding(horizontal = 16.dp)) + ContentMaker( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) + ContentImagesRow(images = CheeseImages.subList(0, 2)) + Spacer(modifier = Modifier.height(1.dp)) + ContentImagesRow(images = CheeseImages.subList(2, 5)) + ContentBody(maxLines = 2, modifier = Modifier.padding(16.dp)) + } else { + ContentMaker(modifier = Modifier.padding(horizontal = 16.dp)) + ContentTitle(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + ContentBody(maxLines = 1, Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) + ContentImagesRow(images = CheeseImages) + } + } +} + +@Composable +private fun ContentTitle(modifier: Modifier = Modifier) { + Text( + text = "Cheeses", + modifier = modifier, + style = MaterialTheme.typography.titleLarge + ) +} + +@Composable +private fun ContentMaker(modifier: Modifier = Modifier) { + Text( + text = "Maker: Android Cheese", + modifier = modifier, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) +} + +@Composable +private fun CardIcon(modifier: Modifier = Modifier) { + Image( + painter = painterResource(R.drawable.cheese_1), + contentDescription = null, + modifier = modifier + .size(48.dp) + .clip(CircleShape) + ) +} + +@Composable +private fun ContentImagesRow(images: List>, modifier: Modifier = Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(1.dp)) { + for ((resourceId, contentDescription) in images) { + Image( + painter = painterResource(resourceId), + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + ) + } + } +} + +@Composable +private fun ContentBody(maxLines: Int, modifier: Modifier = Modifier) { + Text( + text = LoremIpsum(32).values.joinToString(" ").replace('\n', ' '), + modifier = modifier, + style = MaterialTheme.typography.bodyMedium, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Justify, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewExpandedContent() { + SnippetsTheme { + SharedTransformDemo() + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementBlurSnippet.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementBlurSnippet.kt index 92bee9098..e07cbec6f 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementBlurSnippet.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementBlurSnippet.kt @@ -157,7 +157,7 @@ fun SharedTransitionScope.SnackItem( SnackContents( snack = snack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = snack.name), + sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility, boundsTransform = boundsTransition, ), diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementSnippets.kt index 955dbe460..5a51b8d56 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/AnimatedVisibilitySharedElementSnippets.kt @@ -111,7 +111,7 @@ private fun AnimatedVisibilitySharedElementShortenedExample() { SnackContents( snack = snack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = snack.name), + sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { @@ -175,7 +175,7 @@ fun SharedTransitionScope.SnackEditDetails( SnackContents( snack = targetSnack, modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = targetSnack.name), + sharedContentState = rememberSharedContentState(key = targetSnack.name), animatedVisibilityScope = this@AnimatedContent, ), onClick = { diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/CustomizeSharedElementsSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/CustomizeSharedElementsSnippets.kt index 3e60c10e8..079c68347 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/CustomizeSharedElementsSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/CustomizeSharedElementsSnippets.kt @@ -18,6 +18,7 @@ package com.example.compose.snippets.animations.sharedelement +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -30,7 +31,9 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.ArcMode import androidx.compose.animation.core.ExperimentalAnimationSpecApi import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.SeekableTransitionState import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -60,12 +63,15 @@ import androidx.compose.material.icons.outlined.Create import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Icon +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,6 +91,8 @@ import androidx.navigation.navArgument import com.example.compose.snippets.R import com.example.compose.snippets.ui.theme.LavenderLight import com.example.compose.snippets.ui.theme.RoseLight +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.launch @Preview @Composable @@ -628,3 +636,97 @@ fun PlaceholderSizeAnimated_Demo() { } // [END android_compose_shared_element_placeholder_size] } + +private sealed class Screen { + data object Home : Screen() + data class Details(val id: Int) : Screen() +} + +@Preview +@Composable +fun CustomPredictiveBackHandle() { + // [START android_compose_shared_element_custom_seeking] + val seekableTransitionState = remember { + SeekableTransitionState(Screen.Home) + } + val transition = rememberTransition(transitionState = seekableTransitionState) + + PredictiveBackHandler(seekableTransitionState.currentState is Screen.Details) { progress -> + try { + // Whilst a back gesture is in progress, backEvents will be fired for each progress + // update. + progress.collect { backEvent -> + // For each backEvent that comes in, we manually seekTo the reported back progress + try { + seekableTransitionState.seekTo(backEvent.progress, targetState = Screen.Home) + } catch (_: CancellationException) { + // seekTo may be cancelled as expected, if animateTo or subsequent seekTo calls + // before the current seekTo finishes, in this case, we ignore the cancellation. + } + } + // Once collection has completed, we are either fully in the target state, or need + // to progress towards the end. + seekableTransitionState.animateTo(seekableTransitionState.targetState) + } catch (e: CancellationException) { + // When the predictive back gesture is cancelled, we snap to the end state to ensure + // it completes its seeking animation back to the currentState + seekableTransitionState.snapTo(seekableTransitionState.currentState) + throw e + } + } + val coroutineScope = rememberCoroutineScope() + var lastNavigatedIndex by remember { + mutableIntStateOf(0) + } + Column { + Slider( + modifier = Modifier.height(48.dp), + value = seekableTransitionState.fraction, + onValueChange = { + coroutineScope.launch { + if (seekableTransitionState.currentState is Screen.Details) { + seekableTransitionState.seekTo(it, Screen.Home) + } else { + // seek to the previously navigated index + seekableTransitionState.seekTo(it, Screen.Details(lastNavigatedIndex)) + } + } + } + ) + SharedTransitionLayout(modifier = Modifier.weight(1f)) { + transition.AnimatedContent { targetState -> + when (targetState) { + Screen.Home -> { + HomeScreen( + this@SharedTransitionLayout, + this@AnimatedContent, + onItemClick = { + coroutineScope.launch { + lastNavigatedIndex = it + seekableTransitionState.animateTo(Screen.Details(it)) + } + } + ) + } + + is Screen.Details -> { + val snack = listSnacks[targetState.id] + DetailsScreen( + targetState.id, + snack, + this@SharedTransitionLayout, + this@AnimatedContent, + onBackPressed = { + coroutineScope.launch { + seekableTransitionState.animateTo(Screen.Home) + } + } + ) + } + } + } + } + } + + // [END android_compose_shared_element_custom_seeking] +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/SharedElementsWithNavigationSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/SharedElementsWithNavigationSnippets.kt index da1d178fb..cac68a586 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/SharedElementsWithNavigationSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/animations/sharedelement/SharedElementsWithNavigationSnippets.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -75,9 +74,9 @@ fun SharedElement_PredictiveBack() { ) { composable("home") { HomeScreen( - navController, this@SharedTransitionLayout, - this@composable + this@composable, + { navController.navigate("details/$it") } ) } composable( @@ -87,11 +86,13 @@ fun SharedElement_PredictiveBack() { val id = backStackEntry.arguments?.getInt("item") val snack = listSnacks[id!!] DetailsScreen( - navController, id, snack, this@SharedTransitionLayout, - this@composable + this@composable, + { + navController.navigate("home") + } ) } } @@ -99,19 +100,19 @@ fun SharedElement_PredictiveBack() { } @Composable -private fun DetailsScreen( - navController: NavHostController, +fun DetailsScreen( id: Int, snack: Snack, sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope + animatedContentScope: AnimatedContentScope, + onBackPressed: () -> Unit ) { with(sharedTransitionScope) { Column( Modifier .fillMaxSize() .clickable { - navController.navigate("home") + onBackPressed() } ) { Image( @@ -141,10 +142,10 @@ private fun DetailsScreen( } @Composable -private fun HomeScreen( - navController: NavHostController, +fun HomeScreen( sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope + animatedContentScope: AnimatedContentScope, + onItemClick: (Int) -> Unit, ) { LazyColumn( modifier = Modifier @@ -155,7 +156,7 @@ private fun HomeScreen( itemsIndexed(listSnacks) { index, item -> Row( Modifier.clickable { - navController.navigate("details/$index") + onItemClick(index) } ) { Spacer(modifier = Modifier.width(8.dp)) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/IconButton.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/IconButton.kt new file mode 100644 index 000000000..59727c71b --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/IconButton.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.example.compose.snippets.R +import kotlinx.coroutines.delay + +// [START android_compose_components_togglebuttonexample] +@Preview +@Composable +fun ToggleIconButtonExample() { + // isToggled initial value should be read from a view model or persistent storage. + var isToggled by rememberSaveable { mutableStateOf(false) } + + IconButton( + onClick = { isToggled = !isToggled } + ) { + Icon( + painter = if (isToggled) painterResource(R.drawable.favorite_filled) else painterResource(R.drawable.favorite), + contentDescription = if (isToggled) "Selected icon button" else "Unselected icon button." + ) + } +} +// [END android_compose_components_togglebuttonexample] + +// [START android_compose_components_iconbutton] +@Composable +fun MomentaryIconButton( + unselectedImage: Int, + selectedImage: Int, + contentDescription: String, + modifier: Modifier = Modifier, + stepDelay: Long = 100L, // Minimum value is 1L milliseconds. + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedListener by rememberUpdatedState(onClick) + + LaunchedEffect(isPressed) { + while (isPressed) { + delay(stepDelay.coerceIn(1L, Long.MAX_VALUE)) + pressedListener() + } + } + + IconButton( + modifier = modifier, + onClick = onClick, + interactionSource = interactionSource + ) { + Icon( + painter = if (isPressed) painterResource(id = selectedImage) else painterResource(id = unselectedImage), + contentDescription = contentDescription, + ) + } +} +// [END android_compose_components_iconbutton] + +// [START android_compose_components_momentaryiconbuttons] +@Preview() +@Composable +fun MomentaryIconButtonExample() { + var pressedCount by remember { mutableIntStateOf(0) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + MomentaryIconButton( + unselectedImage = R.drawable.fast_rewind, + selectedImage = R.drawable.fast_rewind_filled, + stepDelay = 100L, + onClick = { pressedCount -= 1 }, + contentDescription = "Decrease count button" + ) + Spacer(modifier = Modifier) + Text("advanced by $pressedCount frames") + Spacer(modifier = Modifier) + MomentaryIconButton( + unselectedImage = R.drawable.fast_forward, + selectedImage = R.drawable.fast_forward_filled, + contentDescription = "Increase count button", + stepDelay = 100L, + onClick = { pressedCount += 1 } + ) + } +} +// [END android_compose_components_momentaryiconbuttons] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/Navigation.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/Navigation.kt new file mode 100644 index 000000000..9cad68934 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/Navigation.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.PlaylistAddCircle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController + +@Composable +fun SongsScreen(modifier: Modifier = Modifier) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Songs Screen") + } +} + +@Composable +fun AlbumScreen(modifier: Modifier = Modifier) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Album Screen") + } +} + +@Composable +fun PlaylistScreen(modifier: Modifier = Modifier) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Playlist Screen") + } +} + +enum class Destination( + val route: String, + val label: String, + val icon: ImageVector, + val contentDescription: String +) { + SONGS("songs", "Songs", Icons.Default.MusicNote, "Songs"), + ALBUM("album", "Album", Icons.Default.Album, "Album"), + PLAYLISTS("playlist", "Playlist", Icons.Default.PlaylistAddCircle, "Playlist") +} + +@Composable +fun AppNavHost( + navController: NavHostController, + startDestination: Destination, + modifier: Modifier = Modifier +) { + NavHost( + navController, + startDestination = startDestination.route + ) { + Destination.entries.forEach { destination -> + composable(destination.route) { + when (destination) { + Destination.SONGS -> SongsScreen() + Destination.ALBUM -> AlbumScreen() + Destination.PLAYLISTS -> PlaylistScreen() + } + } + } + } +} + +@Preview() +// [START android_compose_components_navigationbarexample] +@Composable +fun NavigationBarExample(modifier: Modifier = Modifier) { + val navController = rememberNavController() + val startDestination = Destination.SONGS + var selectedDestination by rememberSaveable { mutableIntStateOf(startDestination.ordinal) } + + Scaffold( + modifier = modifier, + bottomBar = { + NavigationBar(windowInsets = NavigationBarDefaults.windowInsets) { + Destination.entries.forEachIndexed { index, destination -> + NavigationBarItem( + selected = selectedDestination == index, + onClick = { + navController.navigate(route = destination.route) + selectedDestination = index + }, + icon = { + Icon( + destination.icon, + contentDescription = destination.contentDescription + ) + }, + label = { Text(destination.label) } + ) + } + } + } + ) { contentPadding -> + AppNavHost(navController, startDestination, modifier = Modifier.padding(contentPadding)) + } +} +// [END android_compose_components_navigationbarexample] + +@Preview() +// [START android_compose_components_navigationrailexample] +@Composable +fun NavigationRailExample(modifier: Modifier = Modifier) { + val navController = rememberNavController() + val startDestination = Destination.SONGS + var selectedDestination by rememberSaveable { mutableIntStateOf(startDestination.ordinal) } + + Scaffold(modifier = modifier) { contentPadding -> + NavigationRail(modifier = Modifier.padding(contentPadding)) { + Destination.entries.forEachIndexed { index, destination -> + NavigationRailItem( + selected = selectedDestination == index, + onClick = { + navController.navigate(route = destination.route) + selectedDestination = index + }, + icon = { + Icon( + destination.icon, + contentDescription = destination.contentDescription + ) + }, + label = { Text(destination.label) } + ) + } + } + AppNavHost(navController, startDestination) + } +} +// [END android_compose_components_navigationrailexample] + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +// [START android_compose_components_navigationtabexample] +@Composable +fun NavigationTabExample(modifier: Modifier = Modifier) { + val navController = rememberNavController() + val startDestination = Destination.SONGS + var selectedDestination by rememberSaveable { mutableIntStateOf(startDestination.ordinal) } + + Scaffold(modifier = modifier) { contentPadding -> + PrimaryTabRow(selectedTabIndex = selectedDestination, modifier = Modifier.padding(contentPadding)) { + Destination.entries.forEachIndexed { index, destination -> + Tab( + selected = selectedDestination == index, + onClick = { + navController.navigate(route = destination.route) + selectedDestination = index + }, + text = { + Text( + text = destination.label, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + AppNavHost(navController, startDestination) + } +} +// [END android_compose_components_navigationtabexample] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/RadioButton.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/RadioButton.kt new file mode 100644 index 000000000..93961f245 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/RadioButton.kt @@ -0,0 +1,77 @@ +/* + * 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 + * + * https://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.example.compose.snippets.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +// [START android_compose_components_radiobuttonsingleselection] +@Composable +fun RadioButtonSingleSelection(modifier: Modifier = Modifier) { + val radioOptions = listOf("Calls", "Missed", "Friends") + val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[0]) } + // Note that Modifier.selectableGroup() is essential to ensure correct accessibility behavior + Column(modifier.selectableGroup()) { + radioOptions.forEach { text -> + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = (text == selectedOption), + onClick = { onOptionSelected(text) }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (text == selectedOption), + onClick = null // null recommended for accessibility with screen readers + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } +} +// [END android_compose_components_radiobuttonsingleselection] + +@Preview +@Composable +private fun RadioButtonSingleSelectionPreview() { + RadioButtonSingleSelection() +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/SearchBar.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/SearchBar.kt new file mode 100644 index 000000000..f3243e299 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/SearchBar.kt @@ -0,0 +1,299 @@ +/* + * 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 + * + * https://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.example.compose.snippets.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun SearchBarExamples() { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + var currentExample by remember { mutableStateOf(null) } + + when (currentExample) { + "simple" -> SimpleSearchBarExample() + "fancy" -> CustomizableSearchBarExample() + else -> { + Button(onClick = { currentExample = "simple" }) { + Text("Simple SearchBar") + } + Button(onClick = { currentExample = "fancy" }) { + Text("Customizable SearchBar") + } + } + } + } +} + +// [START android_compose_components_simple_searchbar] +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleSearchBar( + textFieldState: TextFieldState, + onSearch: (String) -> Unit, + searchResults: List, + modifier: Modifier = Modifier +) { + // Controls expansion state of the search bar + var expanded by rememberSaveable { mutableStateOf(false) } + + Box( + modifier + .fillMaxSize() + .semantics { isTraversalGroup = true } + ) { + SearchBar( + modifier = Modifier + .align(Alignment.TopCenter) + .semantics { traversalIndex = 0f }, + inputField = { + SearchBarDefaults.InputField( + query = textFieldState.text.toString(), + onQueryChange = { textFieldState.edit { replace(0, length, it) } }, + onSearch = { + onSearch(textFieldState.text.toString()) + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("Search") } + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + // Display search results in a scrollable column + Column(Modifier.verticalScroll(rememberScrollState())) { + searchResults.forEach { result -> + ListItem( + headlineContent = { Text(result) }, + modifier = Modifier + .clickable { + textFieldState.edit { replace(0, length, result) } + expanded = false + } + .fillMaxWidth() + ) + } + } + } + } +} +// [END android_compose_components_simple_searchbar] + +@Preview(showBackground = true) +@Composable +private fun SimpleSearchBarExample() { + // Create and remember the text field state + val textFieldState = rememberTextFieldState() + val items = listOf( + "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", + "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop" + ) + + // Filter items based on the current search text + val filteredItems by remember { + derivedStateOf { + val searchText = textFieldState.text.toString() + if (searchText.isEmpty()) { + emptyList() + } else { + items.filter { it.contains(searchText, ignoreCase = true) } + } + } + } + + SimpleSearchBar( + textFieldState = textFieldState, + onSearch = { /* Handle search submission */ }, + searchResults = filteredItems + ) +} + +// [START android_compose_components_customizable_searchbar] +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomizableSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + searchResults: List, + onResultClick: (String) -> Unit, + // Customization options + placeholder: @Composable () -> Unit = { Text("Search") }, + leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, contentDescription = "Search") }, + trailingIcon: @Composable (() -> Unit)? = null, + supportingContent: (@Composable (String) -> Unit)? = null, + leadingContent: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier +) { + // Track expanded state of search bar + var expanded by rememberSaveable { mutableStateOf(false) } + + Box( + modifier + .fillMaxSize() + .semantics { isTraversalGroup = true } + ) { + SearchBar( + modifier = Modifier + .align(Alignment.TopCenter) + .semantics { traversalIndex = 0f }, + inputField = { + // Customizable input field implementation + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { + onSearch(query) + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + // Show search results in a lazy column for better performance + LazyColumn { + items(count = searchResults.size) { index -> + val resultText = searchResults[index] + ListItem( + headlineContent = { Text(resultText) }, + supportingContent = supportingContent?.let { { it(resultText) } }, + leadingContent = leadingContent, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier + .clickable { + onResultClick(resultText) + expanded = false + } + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + } + } + } +} +// [END android_compose_components_customizable_searchbar] + +@Preview(showBackground = true) +@Composable +fun CustomizableSearchBarExample() { + // Manage query state + var query by rememberSaveable { mutableStateOf("") } + val items = listOf( + "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", + "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop", "Marshmallow", + "Nougat", "Oreo", "Pie" + ) + + // Filter items based on query + val filteredItems by remember { + derivedStateOf { + if (query.isEmpty()) { + items + } else { + items.filter { it.contains(query, ignoreCase = true) } + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + CustomizableSearchBar( + query = query, + onQueryChange = { query = it }, + onSearch = { /* Handle search submission */ }, + searchResults = filteredItems, + onResultClick = { query = it }, + // Customize appearance with optional parameters + placeholder = { Text("Search desserts") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = "More options") }, + supportingContent = { Text("Android dessert") }, + leadingContent = { Icon(Icons.Filled.Star, contentDescription = "Starred item") } + ) + + // Display the filtered list below the search bar + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + top = 72.dp, // Provides space for the search bar + end = 16.dp, + bottom = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.semantics { + traversalIndex = 1f + }, + ) { + items(count = filteredItems.size) { + Text(text = filteredItems[it]) + } + } + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/SwipeToDismissBox.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/SwipeToDismissBox.kt new file mode 100644 index 000000000..22dc99303 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/SwipeToDismissBox.kt @@ -0,0 +1,257 @@ +/* + * 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 + * + * https://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.example.compose.snippets.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart +import androidx.compose.material3.SwipeToDismissBoxValue.Settled +import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun SwipeToDismissBoxExamples() { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Swipe to dismiss with change of background", fontWeight = FontWeight.Bold) + SwipeItemExample() + Text("Swipe to dismiss with a cross-fade animation", fontWeight = FontWeight.Bold) + SwipeItemWithAnimationExample() + } +} + +// [START android_compose_components_todoitem] +data class TodoItem( + val itemDescription: String, + var isItemDone: Boolean = false +) +// [END android_compose_components_todoitem] + +// [START android_compose_components_swipeitem] +@Composable +fun TodoListItem( + todoItem: TodoItem, + onToggleDone: (TodoItem) -> Unit, + onRemove: (TodoItem) -> Unit, + modifier: Modifier = Modifier, +) { + val swipeToDismissBoxState = rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it == StartToEnd) onToggleDone(todoItem) + else if (it == EndToStart) onRemove(todoItem) + // Reset item when toggling done status + it != StartToEnd + } + ) + + SwipeToDismissBox( + state = swipeToDismissBoxState, + modifier = modifier.fillMaxSize(), + backgroundContent = { + when (swipeToDismissBoxState.dismissDirection) { + StartToEnd -> { + Icon( + if (todoItem.isItemDone) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, + contentDescription = if (todoItem.isItemDone) "Done" else "Not done", + modifier = Modifier + .fillMaxSize() + .background(Color.Blue) + .wrapContentSize(Alignment.CenterStart) + .padding(12.dp), + tint = Color.White + ) + } + EndToStart -> { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove item", + modifier = Modifier + .fillMaxSize() + .background(Color.Red) + .wrapContentSize(Alignment.CenterEnd) + .padding(12.dp), + tint = Color.White + ) + } + Settled -> {} + } + } + ) { + ListItem( + headlineContent = { Text(todoItem.itemDescription) }, + supportingContent = { Text("swipe me to update or remove.") } + ) + } +} +// [END android_compose_components_swipeitem] + +@Preview(showBackground = true) +// [START android_compose_components_swipeitemexample] +@Composable +private fun SwipeItemExample() { + val todoItems = remember { + mutableStateListOf( + TodoItem("Pay bills"), TodoItem("Buy groceries"), + TodoItem("Go to gym"), TodoItem("Get dinner") + ) + } + + LazyColumn { + items( + items = todoItems, + key = { it.itemDescription } + ) { todoItem -> + TodoListItem( + todoItem = todoItem, + onToggleDone = { todoItem -> + todoItem.isItemDone = !todoItem.isItemDone + }, + onRemove = { todoItem -> + todoItems -= todoItem + }, + modifier = Modifier.animateItem() + ) + } + } +} +// [END android_compose_components_swipeitemexample] + +// [START android_compose_components_swipecarditem] +@Composable +fun TodoListItemWithAnimation( + todoItem: TodoItem, + onToggleDone: (TodoItem) -> Unit, + onRemove: (TodoItem) -> Unit, + modifier: Modifier = Modifier, +) { + val swipeToDismissBoxState = rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it == StartToEnd) onToggleDone(todoItem) + else if (it == EndToStart) onRemove(todoItem) + // Reset item when toggling done status + it != StartToEnd + } + ) + + SwipeToDismissBox( + state = swipeToDismissBoxState, + modifier = modifier.fillMaxSize(), + backgroundContent = { + when (swipeToDismissBoxState.dismissDirection) { + StartToEnd -> { + Icon( + if (todoItem.isItemDone) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, + contentDescription = if (todoItem.isItemDone) "Done" else "Not done", + modifier = Modifier + .fillMaxSize() + .drawBehind { + drawRect(lerp(Color.LightGray, Color.Blue, swipeToDismissBoxState.progress)) + } + .wrapContentSize(Alignment.CenterStart) + .padding(12.dp), + tint = Color.White + ) + } + EndToStart -> { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove item", + modifier = Modifier + .fillMaxSize() + .background(lerp(Color.LightGray, Color.Red, swipeToDismissBoxState.progress)) + .wrapContentSize(Alignment.CenterEnd) + .padding(12.dp), + tint = Color.White + ) + } + Settled -> {} + } + } + ) { + OutlinedCard(shape = RectangleShape) { + ListItem( + headlineContent = { Text(todoItem.itemDescription) }, + supportingContent = { Text("swipe me to update or remove.") } + ) + } + } +} +// [END android_compose_components_swipecarditem] + +@Preview +// [START android_compose_components_swipecarditemexample] +@Composable +private fun SwipeItemWithAnimationExample() { + val todoItems = remember { + mutableStateListOf( + TodoItem("Pay bills"), TodoItem("Buy groceries"), + TodoItem("Go to gym"), TodoItem("Get dinner") + ) + } + + LazyColumn { + items( + items = todoItems, + key = { it.itemDescription } + ) { todoItem -> + TodoListItemWithAnimation( + todoItem = todoItem, + onToggleDone = { todoItem -> + todoItem.isItemDone = !todoItem.isItemDone + }, + onRemove = { todoItem -> + todoItems -= todoItem + }, + modifier = Modifier.animateItem() + ) + } + } +} +// [END android_compose_components_swipecarditemexample] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/components/Tooltips.kt b/compose/snippets/src/main/java/com/example/compose/snippets/components/Tooltips.kt index 1b5d9ea04..1066d0cfb 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/components/Tooltips.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/components/Tooltips.kt @@ -36,14 +36,14 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch @Composable fun TooltipExamples() { @@ -147,6 +147,7 @@ fun AdvancedRichTooltipExample( richTooltipActionText: String = "Dismiss" ) { val tooltipState = rememberTooltipState() + val coroutineScope = rememberCoroutineScope() TooltipBox( modifier = modifier, @@ -156,7 +157,11 @@ fun AdvancedRichTooltipExample( title = { Text(richTooltipSubheadText) }, action = { Row { - TextButton(onClick = { tooltipState.dismiss() }) { + TextButton(onClick = { + coroutineScope.launch { + tooltipState.dismiss() + } + }) { Text(richTooltipActionText) } } @@ -168,7 +173,11 @@ fun AdvancedRichTooltipExample( }, state = tooltipState ) { - IconButton(onClick = { tooltipState.dismiss() }) { + IconButton(onClick = { + coroutineScope.launch { + tooltipState.show() + } + }) { Icon( imageVector = Icons.Filled.Camera, contentDescription = "Open camera" diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/draganddrop/DragAndDropSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/draganddrop/DragAndDropSnippets.kt index 46e245a3f..12542079e 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/draganddrop/DragAndDropSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/draganddrop/DragAndDropSnippets.kt @@ -20,11 +20,11 @@ import android.content.ClipData import android.content.ClipDescription import android.os.Build import android.view.View +import androidx.activity.compose.LocalActivity import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -32,6 +32,7 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent @RequiresApi(Build.VERSION_CODES.N) @OptIn(ExperimentalFoundationApi::class) @@ -40,40 +41,24 @@ private fun DragAndDropSnippet() { val url = "" - // [START android_compose_drag_and_drop_1] - Modifier.dragAndDropSource { - detectTapGestures(onLongPress = { - // Transfer data here. - }) - } - // [END android_compose_drag_and_drop_1] - // [START android_compose_drag_and_drop_2] - Modifier.dragAndDropSource { - detectTapGestures(onLongPress = { - startTransfer( - DragAndDropTransferData( - ClipData.newPlainText( - "image Url", url - ) - ) + Modifier.dragAndDropSource { _ -> + DragAndDropTransferData( + ClipData.newPlainText( + "image Url", url ) - }) + ) } // [END android_compose_drag_and_drop_2] // [START android_compose_drag_and_drop_3] - Modifier.dragAndDropSource { - detectTapGestures(onLongPress = { - startTransfer( - DragAndDropTransferData( - ClipData.newPlainText( - "image Url", url - ), - flags = View.DRAG_FLAG_GLOBAL - ) - ) - }) + Modifier.dragAndDropSource { _ -> + DragAndDropTransferData( + ClipData.newPlainText( + "image Url", url + ), + flags = View.DRAG_FLAG_GLOBAL + ) } // [END android_compose_drag_and_drop_3] @@ -88,11 +73,27 @@ private fun DragAndDropSnippet() { } // [END android_compose_drag_and_drop_4] + LocalActivity.current?.let { activity -> + // [START android_compose_drag_and_drop_7] + val externalAppCallback = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val permission = + activity.requestDragAndDropPermissions(event.toAndroidDragEvent()) + // Parse received data + permission?.release() + return true + } + } + } + // [END android_compose_drag_and_drop_7] + } + // [START android_compose_drag_and_drop_5] Modifier.dragAndDropTarget( shouldStartDragAndDrop = { event -> event.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_PLAIN) - }, target = callback + }, target = callback // or externalAppCallback ) // [END android_compose_drag_and_drop_5] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlancePinAppWidget.kt b/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlancePinAppWidget.kt new file mode 100644 index 000000000..428212068 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlancePinAppWidget.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.glance + +import android.content.Context +import androidx.compose.material3.Button +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import kotlinx.coroutines.launch + +class MyWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MyWidget() +} + +class MyWidget : GlanceAppWidget() { + override suspend fun provideGlance( + context: Context, + id: GlanceId + ) {} +} + +// [START android_compose_glance_in_app_pinning] +@Composable +fun AnInAppComposable() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + Button( + onClick = { + coroutineScope.launch { + GlanceAppWidgetManager(context).requestPinGlanceAppWidget( + receiver = MyWidgetReceiver::class.java, + preview = MyWidget(), + previewState = DpSize(245.dp, 115.dp) + ) + } + } + ) {} +} +// [END android_compose_glance_in_app_pinning] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlanceSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlanceSnippets.kt index e1300358b..bb575a8b5 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlanceSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/glance/GlanceSnippets.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.glance.Button @@ -69,6 +70,7 @@ import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionSendBroadcast import androidx.glance.appwidget.action.actionStartService +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateIf @@ -868,6 +870,32 @@ object GlanceTheming { } } +object GlanceInnerPadding { + + // [START android_compose_glance_innercornerradius] + /** + * Applies corner radius for views that are visually positioned [widgetPadding]dp inside of the + * widget background. + */ + @Composable + fun GlanceModifier.appWidgetInnerCornerRadius(widgetPadding: Dp): GlanceModifier { + + if (Build.VERSION.SDK_INT < 31) { + return this + } + + val resources = LocalContext.current.resources + // get dimension in float (without rounding). + val px = resources.getDimension(android.R.dimen.system_app_widget_background_radius) + val widgetBackgroundRadiusDpValue = px / resources.displayMetrics.density + if (widgetBackgroundRadiusDpValue < widgetPadding.value) { + return this + } + return this.cornerRadius(Dp(widgetBackgroundRadiusDpValue - widgetPadding.value)) + } + // [END android_compose_glance_innercornerradius] +} + object GlanceInteroperability { @Composable fun example01() { diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/AdaptiveLayoutSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/AdaptiveLayoutSnippets.kt index 7980eae05..9447582cf 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/AdaptiveLayoutSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/AdaptiveLayoutSnippets.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp -import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowSizeClass /* @@ -51,10 +50,10 @@ import androidx.window.core.layout.WindowSizeClass fun MyApp( windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass ) { - // Perform logic on the size class to decide whether to show the top app bar. - val showTopAppBar = windowSizeClass.windowHeightSizeClass != WindowHeightSizeClass.COMPACT + // Decide whether to show the top app bar based on window size class. + val showTopAppBar = windowSizeClass.isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND) - // MyScreen knows nothing about window sizes, and performs logic based on a Boolean flag. + // MyScreen logic is based on the showTopAppBar boolean flag. MyScreen( showTopAppBar = showTopAppBar, /* ... */ diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/InsetsSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/InsetsSnippets.kt index 4599fd392..77568686d 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/InsetsSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/InsetsSnippets.kt @@ -22,7 +22,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -30,8 +29,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only @@ -49,7 +46,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -124,7 +120,6 @@ fun ConsumedFromPaddingSnippet() { @Preview @Composable fun M3SupportScaffoldSnippet() { - val colors = listOf(Color.Red, Color.Blue, Color.Yellow) // [START android_compose_insets_m3_scaffold] Scaffold { innerPadding -> // innerPadding contains inset information for you to use and apply @@ -133,14 +128,7 @@ fun M3SupportScaffoldSnippet() { modifier = Modifier.consumeWindowInsets(innerPadding), contentPadding = innerPadding ) { - items(count = 100) { - Box( - Modifier - .fillMaxWidth() - .height(50.dp) - .background(colors[it % colors.size]) - ) - } + // .. } } // [END android_compose_insets_m3_scaffold] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/IntrinsicSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/IntrinsicSnippets.kt index 9bc39b292..edca298d7 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/IntrinsicSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/IntrinsicSnippets.kt @@ -39,10 +39,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,7 +71,7 @@ private object IntrinsicsSnippet1 { .wrapContentWidth(Alignment.Start), text = text1 ) - HorizontalDivider( + VerticalDivider( color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp) ) @@ -111,7 +111,7 @@ private object IntrinsicsSnippet2 { .wrapContentWidth(Alignment.Start), text = text1 ) - HorizontalDivider( + VerticalDivider( color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp) ) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/layouts/SystemBarProtectionSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/SystemBarProtectionSnippets.kt new file mode 100644 index 000000000..429e7ffd1 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/layouts/SystemBarProtectionSnippets.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.layouts + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.compose.snippets.designsystems.MyTheme + +// [START android_compose_system_bar_protection] +class SystemBarProtectionSnippets : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // enableEdgeToEdge sets window.isNavigationBarContrastEnforced = true + // which is used to add a translucent scrim to three-button navigation + enableEdgeToEdge() + + setContent { + MyTheme { + // Main content + MyContent() + + // After drawing main content, draw status bar protection + StatusBarProtection() + } + } + } +} + +@Composable +private fun StatusBarProtection( + color: Color = MaterialTheme.colorScheme.surfaceContainer, + heightProvider: () -> Float = calculateGradientHeight(), +) { + + Canvas(Modifier.fillMaxSize()) { + val calculatedHeight = heightProvider() + val gradient = Brush.verticalGradient( + colors = listOf( + color.copy(alpha = 1f), + color.copy(alpha = .8f), + Color.Transparent + ), + startY = 0f, + endY = calculatedHeight + ) + drawRect( + brush = gradient, + size = Size(size.width, calculatedHeight), + ) + } +} + +@Composable +fun calculateGradientHeight(): () -> Float { + val statusBars = WindowInsets.statusBars + val density = LocalDensity.current + return { statusBars.getTop(density).times(1.2f) } +} +// [END android_compose_system_bar_protection] + +@Composable +fun MyContent() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val colorScheme = MaterialTheme.colorScheme + val loremIpsum = LoremIpsum() + + LazyColumn( + contentPadding = innerPadding + ) { + items(13) { index -> + Card( + modifier = Modifier.fillMaxWidth().padding(8.dp), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = loremIpsum.titles[index], + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = colorScheme.onSurfaceVariant + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = loremIpsum.descriptions[index], + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + color = colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + } +} + +class LoremIpsum { + private val lorem = "First item of the list." + + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + + "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo." + + "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt." + + "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem." + + "Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?" + + "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga." + + "Et harum quidem rerum facilis est et expedita distinctio." + + "Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus." + + "Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae." + + "Last item of the list." + val descriptions = lorem.split(".").map { description -> + description.trim() + } + val titles = descriptions.map { sentence -> + sentence.trim().split(" ").take(2).joinToString(" ") + } +} + +@Preview +@Composable +fun MyContentPreview() { + MyTheme { + MyContent() + } +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/modifiers/CustomModifierSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/modifiers/CustomModifierSnippets.kt index 7b778ef25..650c4beb8 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/modifiers/CustomModifierSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/modifiers/CustomModifierSnippets.kt @@ -18,6 +18,7 @@ package com.example.compose.snippets.modifiers import android.annotation.SuppressLint import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloatAsState @@ -259,7 +260,7 @@ class ScrollableNode : object CustomModifierSnippets14 { // [START android_compose_custom_modifiers_14] class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode { - private val alpha = Animatable(1f) + private lateinit var alpha: Animatable override fun ContentDrawScope.draw() { drawCircle(color = color, alpha = alpha.value) @@ -267,6 +268,7 @@ object CustomModifierSnippets14 { } override fun onAttach() { + alpha = Animatable(1f) coroutineScope.launch { alpha.animateTo( 0f, diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt b/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt index 783963906..8d86b28b4 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/navigation/Destination.kt @@ -49,5 +49,7 @@ enum class TopComponentsDestination(val route: String, val title: String) { MenusExample("menusExamples", "Menus"), TooltipExamples("tooltipExamples", "Tooltips"), NavigationDrawerExamples("navigationDrawerExamples", "Navigation drawer"), - SegmentedButtonExamples("segmentedButtonExamples", "Segmented button") + SegmentedButtonExamples("segmentedButtonExamples", "Segmented button"), + SwipeToDismissBoxExamples("swipeToDismissBoxExamples", "Swipe to dismiss box examples"), + SearchBarExamples("searchBarExamples", "Search bar") } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/predictiveback/PredictiveBackSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/predictiveback/PredictiveBackSnippets.kt index 88ec5c4a1..f9999f67c 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/predictiveback/PredictiveBackSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/predictiveback/PredictiveBackSnippets.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -46,6 +47,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +@Serializable data object Home +@Serializable data object Settings @Composable private fun PredictiveBackOverrideExit( @@ -56,7 +61,7 @@ private fun PredictiveBackOverrideExit( // [START android_compose_predictiveback_navhost] NavHost( navController = navController, - startDestination = "home", + startDestination = Home, popExitTransition = { scaleOut( targetScale = 0.9f, @@ -70,13 +75,13 @@ private fun PredictiveBackOverrideExit( ) // [END android_compose_predictiveback_navhost] { - composable("home") { + composable { HomeScreen( modifier = modifier, navController = navController, ) } - composable("settings") { + composable { SettingsScreen( modifier = modifier, navController = navController, @@ -106,7 +111,10 @@ private fun PredictiveBackHandlerBasicExample() { Box( modifier = Modifier - .fillMaxSize(boxScale) + .graphicsLayer { + scaleX = boxScale + scaleY = scaleX + } .background(Color.Blue) ) @@ -119,9 +127,11 @@ private fun PredictiveBackHandlerBasicExample() { boxScale = 1F - (1F * backEvent.progress) } // code for completion + boxScale = 0F } catch (e: CancellationException) { // code for cancellation boxScale = 1F + throw e } } // [END android_compose_predictivebackhandler_basic] @@ -175,8 +185,10 @@ private fun PredictiveBackHandlerManualProgress() { closeDrawer(velocityTracker.calculateVelocity().x) } catch (e: CancellationException) { openDrawer(velocityTracker.calculateVelocity().x) + throw e + } finally { + velocityTracker.resetTracking() } - velocityTracker.resetTracking() } // [END android_compose_predictivebackhandler_manualprogress] } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/stylus/StylusSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/stylus/StylusSnippets.kt new file mode 100644 index 000000000..82c785984 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/stylus/StylusSnippets.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +package com.example.compose.snippets.stylus + +import android.view.MotionEvent +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.lifecycle.ViewModel + +class UserViewModel : ViewModel() { + fun processMotionEvent(e: MotionEvent): Boolean { + return true + } +} + +val viewModel = UserViewModel() + +// [START android_compose_stylus_motion_event_access] +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun DrawArea(modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .clipToBounds() + .pointerInteropFilter { + viewModel.processMotionEvent(it) + } + + ) { + // Drawing code here. + } +} +// [END android_compose_stylus_motion_event_access] diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/text/AutofillSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/text/AutofillSnippets.kt new file mode 100644 index 000000000..6e30ba93b --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/text/AutofillSnippets.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.text + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.LocalAutofillHighlightColor +import androidx.compose.foundation.text.input.rememberTextFieldState +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material.TextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalAutofillManager +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.example.compose.snippets.touchinput.Button + +@Composable +fun AddAutofill() { + // [START android_compose_autofill_1] + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.Username } + ) + // [END android_compose_autofill_1] +} + +@Composable +fun AddMultipleTypesOfAutofill() { + // [START android_compose_autofill_2] + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { + contentType = ContentType.Username + ContentType.EmailAddress + } + ) + // [END android_compose_autofill_2] +} + +@Composable +fun AutofillManager() { + // [START android_compose_autofill_3] + val autofillManager = LocalAutofillManager.current + // [END android_compose_autofill_3] +} + +@Composable +fun SaveDataWithAutofill() { + var textFieldValue = remember { + mutableStateOf(TextFieldValue("")) + } + // [START android_compose_autofill_4] + val autofillManager = LocalAutofillManager.current + + Column { + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.NewUsername } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.NewPassword } + ) + } + // [END android_compose_autofill_4] +} + +@Composable +fun SaveDataWithAutofillOnClick() { + // [START android_compose_autofill_5] + val autofillManager = LocalAutofillManager.current + + Column { + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.NewUsername }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.NewPassword }, + ) + + // Submit button + Button(onClick = { autofillManager?.commit() }) { Text("Reset credentials") } + } + // [END android_compose_autofill_5] +} + +@Composable +fun CustomAutofillHighlight(customHighlightColor: Color = Color.Red) { + // [START android_compose_autofill_6] + val customHighlightColor = Color.Red + + CompositionLocalProvider(LocalAutofillHighlightColor provides customHighlightColor) { + TextField( + state = rememberTextFieldState(), + modifier = Modifier.semantics { contentType = ContentType.Username } + ) + } + // [END android_compose_autofill_6] +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/text/FilterText.kt b/compose/snippets/src/main/java/com/example/compose/snippets/text/FilterText.kt new file mode 100644 index 000000000..6967ea4f8 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/text/FilterText.kt @@ -0,0 +1,105 @@ +/* + * 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 + * + * https://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.example.compose.snippets.text + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// [START android_compose_text_filtertextviewmodel] +class FilterTextViewModel : ViewModel() { + private val items = listOf( + "Cupcake", + "Donut", + "Eclair", + "Froyo", + "Gingerbread", + "Honeycomb", + "Ice Cream Sandwich" + ) + + private val _filteredItems = MutableStateFlow(items) + var filteredItems: StateFlow> = _filteredItems + + fun filterText(input: String) { + // This filter returns the full items list when input is an empty string. + _filteredItems.value = items.filter { it.contains(input, ignoreCase = true) } + } +} +// [END android_compose_text_filtertextviewmodel] + +// [START android_compose_text_filtertextview] +@Composable +fun FilterTextView(modifier: Modifier = Modifier, viewModel: FilterTextViewModel = viewModel()) { + val filteredItems by viewModel.filteredItems.collectAsStateWithLifecycle() + var text by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 10.dp) + ) { + OutlinedTextField( + value = text, + onValueChange = { + text = it + viewModel.filterText(text) + }, + label = { Text("Filter Text") }, + modifier = Modifier.fillMaxWidth() + ) + + LazyColumn { + items( + count = filteredItems.size, + key = { index -> filteredItems[index] } + ) { + ListItem( + headlineContent = { Text(filteredItems[it]) }, + modifier = Modifier + .fillParentMaxWidth() + .padding(10.dp) + ) + } + } + } +} +// [END android_compose_text_filtertextview] + +@Preview(showBackground = true) +@Composable +private fun FilterTextViewPreview() { + FilterTextView() +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/text/StateBasedText.kt b/compose/snippets/src/main/java/com/example/compose/snippets/text/StateBasedText.kt new file mode 100644 index 000000000..b43aef25f --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/text/StateBasedText.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.text + +import android.text.TextUtils +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.insert +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.selectAll +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.text.input.then +import androidx.compose.material.OutlinedTextField +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material.TextField +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import androidx.lifecycle.ViewModel + +@Preview +@Composable +fun StateBasedTextSnippets() { + Column() { + // [START android_compose_state_text_0] + TextField( + state = rememberTextFieldState(initialText = "Hello"), + label = { Text("Label") } + ) + // [END android_compose_state_text_0] + + // [START android_compose_state_text_1] + OutlinedTextField( + state = rememberTextFieldState(), + label = { Text("Label") } + ) + // [END android_compose_state_text_1] + } +} + +@Preview +@Composable +fun StyleTextField() { + // [START android_compose_state_text_2] + TextField( + state = rememberTextFieldState("Hello\nWorld\nInvisible"), + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 2), + placeholder = { Text("") }, + textStyle = TextStyle(color = Color.Blue, fontWeight = FontWeight.Bold), + label = { Text("Enter text") }, + modifier = Modifier.padding(20.dp) + ) + // [END android_compose_state_text_2] +} + +@Composable +fun ConfigureLineLimits() { + // [START android_compose_state_text_3] + TextField( + state = rememberTextFieldState(), + lineLimits = TextFieldLineLimits.SingleLine + ) + // [END android_compose_state_text_3] +} + +@Preview +@Composable +fun Multiline() { + Spacer(modifier = Modifier.height(15.dp)) + // [START android_compose_state_text_4] + TextField( + state = rememberTextFieldState("Hello\nWorld\nHello\nWorld"), + lineLimits = TextFieldLineLimits.MultiLine(1, 4) + ) + // [END android_compose_state_text_4] +} + +@Composable +fun StyleWithBrush() { + // [START android_compose_state_text_5] + val brush = remember { + Brush.linearGradient( + colors = listOf(Color.Red, Color.Yellow, Color.Green, Color.Blue, Color.Magenta) + ) + } + TextField( + state = rememberTextFieldState(), textStyle = TextStyle(brush = brush) + ) + // [END android_compose_state_text_5] +} + +@Composable +fun StateHoisting() { + // [START android_compose_state_text_6] + val usernameState = rememberTextFieldState() + TextField( + state = usernameState, + lineLimits = TextFieldLineLimits.SingleLine, + placeholder = { Text("Enter Username") } + ) + // [END android_compose_state_text_6] +} + +@Composable +fun TextFieldInitialState() { + // [START android_compose_state_text_7] + TextField( + state = rememberTextFieldState(initialText = "Username"), + lineLimits = TextFieldLineLimits.SingleLine, + ) + // [END android_compose_state_text_7] +} + +@Composable +fun TextFieldBuffer() { + // [START android_compose_state_text_8] + val phoneNumberState = rememberTextFieldState() + + LaunchedEffect(phoneNumberState) { + phoneNumberState.edit { // TextFieldBuffer scope + append("123456789") + } + } + + TextField( + state = phoneNumberState, + inputTransformation = InputTransformation { // TextFieldBuffer scope + if (asCharSequence().isDigitsOnly()) { + revertAllChanges() + } + }, + outputTransformation = OutputTransformation { + if (length > 0) insert(0, "(") + if (length > 4) insert(4, ")") + if (length > 8) insert(8, "-") + } + ) + // [END android_compose_state_text_8] +} + +@Preview +@Composable +fun EditTextFieldState() { + // [START android_compose_state_text_9] + val usernameState = rememberTextFieldState("I love Android") + // textFieldState.text : I love Android + // textFieldState.selection: TextRange(14, 14) + usernameState.edit { insert(14, "!") } + // textFieldState.text : I love Android! + // textFieldState.selection: TextRange(15, 15) + usernameState.edit { replace(7, 14, "Compose") } + // textFieldState.text : I love Compose! + // textFieldState.selection: TextRange(15, 15) + usernameState.edit { append("!!!") } + // textFieldState.text : I love Compose!!!! + // textFieldState.selection: TextRange(18, 18) + usernameState.edit { selectAll() } + // textFieldState.text : I love Compose!!!! + // textFieldState.selection: TextRange(0, 18) + // [END android_compose_state_text_9] + + // [START android_compose_state_text_10] + usernameState.setTextAndPlaceCursorAtEnd("I really love Android") + // textFieldState.text : I really love Android + // textFieldState.selection : TextRange(21, 21) + // [END android_compose_state_text_10] + + // [START android_compose_state_text_11] + usernameState.clearText() + // textFieldState.text : + // textFieldState.selection : TextRange(0, 0) + // [END android_compose_state_text_11] +} + +class TextFieldViewModel : ViewModel() { + val usernameState = TextFieldState() + fun validateUsername() { + } +} +val textFieldViewModel = TextFieldViewModel() + +@Composable +fun TextFieldKeyboardOptions() { + // [START android_compose_state_text_13] + TextField( + state = textFieldViewModel.usernameState, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + onKeyboardAction = { performDefaultAction -> + textFieldViewModel.validateUsername() + performDefaultAction() + } + ) + // [END android_compose_state_text_13] +} + +@Composable +fun TextFieldInputTransformation() { + // [START android_compose_state_text_14] + TextField( + state = rememberTextFieldState(), + lineLimits = TextFieldLineLimits.SingleLine, + inputTransformation = InputTransformation.maxLength(10) + ) + // [END android_compose_state_text_14] +} + +// [START android_compose_state_text_15] +class CustomInputTransformation : InputTransformation { + override fun TextFieldBuffer.transformInput() { + } +} +// [END android_compose_state_text_15] + +// [START android_compose_state_text_16] +class DigitOnlyInputTransformation : InputTransformation { + override fun TextFieldBuffer.transformInput() { + if (!TextUtils.isDigitsOnly(asCharSequence())) { + revertAllChanges() + } + } +} +// [END android_compose_state_text_16] + +@Composable +fun ChainInputTransformation() { + // [START android_compose_state_text_17] + TextField( + state = rememberTextFieldState(), + inputTransformation = InputTransformation.maxLength(6) + .then(CustomInputTransformation()), + ) + // [END android_compose_state_text_17] +} + +// [START android_compose_state_text_18] +class CustomOutputTransformation : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + } +} +// [END android_compose_state_text_18] + +// [START android_compose_state_text_19] +class PhoneNumberOutputTransformation : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + if (length > 0) insert(0, "(") + if (length > 4) insert(4, ")") + if (length > 8) insert(8, "-") + } +} +// [END android_compose_state_text_19] + +@Composable +fun TextFieldOutputTransformation() { + // [START android_compose_state_text_20] + TextField( + state = rememberTextFieldState(), + outputTransformation = PhoneNumberOutputTransformation() + ) + // [END android_compose_state_text_20] +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/text/TextFieldMigrationSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/text/TextFieldMigrationSnippets.kt new file mode 100644 index 000000000..a9e970266 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/text/TextFieldMigrationSnippets.kt @@ -0,0 +1,345 @@ +/* + * Copyright 2025 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 + * + * https://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.example.compose.snippets.text + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.delete +import androidx.compose.foundation.text.input.insert +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material.SecureTextField +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material.Text +//noinspection UsingMaterialAndMaterial3Libraries +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.substring +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.compose.snippets.touchinput.Button +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update + +// [START android_compose_text_textfield_migration_old_simple] +@Composable +fun OldSimpleTextField() { + var state by rememberSaveable { mutableStateOf("") } + TextField( + value = state, + onValueChange = { state = it }, + singleLine = true, + ) +} +// [END android_compose_text_textfield_migration_old_simple] + +// [START android_compose_text_textfield_migration_new_simple] +@Composable +fun NewSimpleTextField() { + TextField( + state = rememberTextFieldState(), + lineLimits = TextFieldLineLimits.SingleLine + ) +} +// [END android_compose_text_textfield_migration_new_simple] + +// [START android_compose_text_textfield_migration_old_filtering] +@Composable +fun OldNoLeadingZeroes() { + var input by rememberSaveable { mutableStateOf("") } + TextField( + value = input, + onValueChange = { newText -> + input = newText.trimStart { it == '0' } + } + ) +} +// [END android_compose_text_textfield_migration_old_filtering] + +// [START android_compose_text_textfield_migration_new_filtering] + +@Preview +@Composable +fun NewNoLeadingZeros() { + TextField( + state = rememberTextFieldState(), + inputTransformation = InputTransformation { + while (length > 0 && charAt(0) == '0') delete(0, 1) + } + ) +} +// [END android_compose_text_textfield_migration_new_filtering] + +// [START android_compose_text_textfield_migration_old_credit_card_formatter] +@Composable +fun OldTextFieldCreditCardFormatter() { + var state by remember { mutableStateOf("") } + TextField( + value = state, + onValueChange = { if (it.length <= 16) state = it }, + visualTransformation = VisualTransformation { text -> + // Making XXXX-XXXX-XXXX-XXXX string. + var out = "" + for (i in text.indices) { + out += text[i] + if (i % 4 == 3 && i != 15) out += "-" + } + + TransformedText( + text = AnnotatedString(out), + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 3) return offset + if (offset <= 7) return offset + 1 + if (offset <= 11) return offset + 2 + if (offset <= 16) return offset + 3 + return 19 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 9) return offset - 1 + if (offset <= 14) return offset - 2 + if (offset <= 19) return offset - 3 + return 16 + } + } + ) + } + ) +} +// [END android_compose_text_textfield_migration_old_credit_card_formatter] + +// [START android_compose_text_textfield_migration_new_credit_card_formatter] +@Composable +fun NewTextFieldCreditCardFormatter() { + val state = rememberTextFieldState() + TextField( + state = state, + inputTransformation = InputTransformation.maxLength(16), + outputTransformation = OutputTransformation { + if (length > 4) insert(4, "-") + if (length > 9) insert(9, "-") + if (length > 14) insert(14, "-") + }, + ) +} +// [END android_compose_text_textfield_migration_new_credit_card_formatter] + +private object StateUpdateSimpleSnippet { + object UserRepository { + suspend fun fetchUsername(): String = TODO() + } + // [START android_compose_text_textfield_migration_old_update_state_simple] + @Composable + fun OldTextFieldStateUpdate(userRepository: UserRepository) { + var username by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + username = userRepository.fetchUsername() + } + TextField( + value = username, + onValueChange = { username = it } + ) + } + // [END android_compose_text_textfield_migration_old_update_state_simple] + + // [START android_compose_text_textfield_migration_new_update_state_simple] + @Composable + fun NewTextFieldStateUpdate(userRepository: UserRepository) { + val usernameState = rememberTextFieldState() + LaunchedEffect(Unit) { + usernameState.setTextAndPlaceCursorAtEnd(userRepository.fetchUsername()) + } + TextField(state = usernameState) + } + // [END android_compose_text_textfield_migration_new_update_state_simple] +} + +// [START android_compose_text_textfield_migration_old_state_update_complex] +@Composable +fun OldTextFieldAddMarkdownEmphasis() { + var markdownState by remember { mutableStateOf(TextFieldValue()) } + Button(onClick = { + // add ** decorations around the current selection, also preserve the selection + markdownState = with(markdownState) { + copy( + text = buildString { + append(text.take(selection.min)) + append("**") + append(text.substring(selection)) + append("**") + append(text.drop(selection.max)) + }, + selection = TextRange(selection.min + 2, selection.max + 2) + ) + } + }) { + Text("Bold") + } + TextField( + value = markdownState, + onValueChange = { markdownState = it }, + maxLines = 10 + ) +} +// [END android_compose_text_textfield_migration_old_state_update_complex] + +// [START android_compose_text_textfield_migration_new_state_update_complex] +@Composable +fun NewTextFieldAddMarkdownEmphasis() { + val markdownState = rememberTextFieldState() + LaunchedEffect(Unit) { + // add ** decorations around the current selection + markdownState.edit { + insert(originalSelection.max, "**") + insert(originalSelection.min, "**") + selection = TextRange(originalSelection.min + 2, originalSelection.max + 2) + } + } + TextField( + state = markdownState, + lineLimits = TextFieldLineLimits.MultiLine(1, 10) + ) +} +// [END android_compose_text_textfield_migration_new_state_update_complex] + +private object ViewModelMigrationOldSnippet { + // [START android_compose_text_textfield_migration_old_viewmodel] + class LoginViewModel : ViewModel() { + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } + + fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } + } + + data class UiState( + val username: String = "", + val password: String = "" + ) + + @Composable + fun LoginForm( + loginViewModel: LoginViewModel, + modifier: Modifier = Modifier + ) { + val uiState by loginViewModel.uiState.collectAsStateWithLifecycle() + Column(modifier) { + TextField( + value = uiState.username, + onValueChange = { loginViewModel.updateUsername(it) } + ) + TextField( + value = uiState.password, + onValueChange = { loginViewModel.updatePassword(it) }, + visualTransformation = PasswordVisualTransformation() + ) + } + } + // [END android_compose_text_textfield_migration_old_viewmodel] +} + +private object ViewModelMigrationNewSimpleSnippet { + // [START android_compose_text_textfield_migration_new_viewmodel_simple] + class LoginViewModel : ViewModel() { + val usernameState = TextFieldState() + val passwordState = TextFieldState() + } + + @Composable + fun LoginForm( + loginViewModel: LoginViewModel, + modifier: Modifier = Modifier + ) { + Column(modifier) { + TextField(state = loginViewModel.usernameState,) + SecureTextField(state = loginViewModel.passwordState) + } + } + // [END android_compose_text_textfield_migration_new_viewmodel_simple] +} + +private object ViewModelMigrationNewConformingSnippet { + // [START android_compose_text_textfield_migration_new_viewmodel_conforming] + class LoginViewModel : ViewModel() { + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } + + fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } + } + + data class UiState( + val username: String = "", + val password: String = "" + ) + + @Composable + fun LoginForm( + loginViewModel: LoginViewModel, + modifier: Modifier = Modifier + ) { + val initialUiState = remember(loginViewModel) { loginViewModel.uiState.value } + Column(modifier) { + val usernameState = rememberTextFieldState(initialUiState.username) + LaunchedEffect(usernameState) { + snapshotFlow { usernameState.text.toString() }.collectLatest { + loginViewModel.updateUsername(it) + } + } + TextField(usernameState) + + val passwordState = rememberTextFieldState(initialUiState.password) + LaunchedEffect(usernameState) { + snapshotFlow { usernameState.text.toString() }.collectLatest { + loginViewModel.updatePassword(it) + } + } + SecureTextField(passwordState) + } + } + // [END android_compose_text_textfield_migration_new_viewmodel_conforming] +} diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt index 82b5b2d1e..d48412c3c 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/focus/FocusSnippets.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2025 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. @@ -14,20 +14,17 @@ * limitations under the License. */ -@file:Suppress("DEPRECATION_ERROR") - package com.example.compose.snippets.touchinput.focus -import androidx.compose.foundation.Indication -import androidx.compose.foundation.IndicationInstance +import androidx.compose.foundation.IndicationNodeFactory import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -42,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -70,9 +66,13 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.invalidateDraw import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch @Preview @Composable @@ -297,7 +297,7 @@ private fun RequestFocus2() { private fun Capture() { var text by remember { mutableStateOf("") } // [START android_compose_touchinput_focus_capture] - val textField = FocusRequester() + val textField = remember { FocusRequester() } TextField( value = text, @@ -436,45 +436,64 @@ private fun ReactToFocus() { } // [START android_compose_touchinput_focus_advanced_cues] -private class MyHighlightIndicationInstance(isEnabledState: State) : - IndicationInstance { - private val isEnabled by isEnabledState - override fun ContentDrawScope.drawIndication() { +private class MyHighlightIndicationNode(private val interactionSource: InteractionSource) : + Modifier.Node(), DrawModifierNode { + private var isFocused = false + + override fun onAttach() { + coroutineScope.launch { + var focusCount = 0 + interactionSource.interactions.collect { interaction -> + when (interaction) { + is FocusInteraction.Focus -> focusCount++ + is FocusInteraction.Unfocus -> focusCount-- + } + val focused = focusCount > 0 + if (isFocused != focused) { + isFocused = focused + invalidateDraw() + } + } + } + } + + override fun ContentDrawScope.draw() { drawContent() - if (isEnabled) { + if (isFocused) { drawRect(size = size, color = Color.White, alpha = 0.2f) } } } + // [END android_compose_touchinput_focus_advanced_cues] // [START android_compose_touchinput_focus_indication] -class MyHighlightIndication : Indication { - @Composable - override fun rememberUpdatedInstance(interactionSource: InteractionSource): - IndicationInstance { - val isFocusedState = interactionSource.collectIsFocusedAsState() - return remember(interactionSource) { - MyHighlightIndicationInstance(isEnabledState = isFocusedState) - } +object MyHighlightIndication : IndicationNodeFactory { + override fun create(interactionSource: InteractionSource): DelegatableNode { + return MyHighlightIndicationNode(interactionSource) } + + override fun hashCode(): Int = -1 + + override fun equals(other: Any?) = other === this } // [END android_compose_touchinput_focus_indication] @Composable private fun ApplyIndication() { // [START android_compose_touchinput_focus_apply_indication] - val highlightIndication = remember { MyHighlightIndication() } var interactionSource = remember { MutableInteractionSource() } Card( modifier = Modifier .clickable( interactionSource = interactionSource, - indication = highlightIndication, + indication = MyHighlightIndication, enabled = true, onClick = { } ) - ) {} + ) { + Text("hello") + } // [END android_compose_touchinput_focus_apply_indication] } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt index a7f21362f..610143cec 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/keyboardinput/KeyboardShortcutsHelper.kt @@ -16,7 +16,6 @@ package com.example.compose.snippets.touchinput.keyboardinput -import android.app.Activity import android.os.Build import android.os.Bundle import android.view.KeyEvent @@ -24,13 +23,13 @@ import android.view.KeyboardShortcutGroup import android.view.KeyboardShortcutInfo import android.view.Menu import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.ui.platform.LocalContext class MainActivity : ComponentActivity() { // Activity codes such as overridden onStart method. @@ -66,7 +65,7 @@ class AnotherActivity : ComponentActivity() { setContent { MaterialTheme { // [START android_compose_keyboard_shortcuts_helper_request] - val activity = LocalContext.current as? Activity + val activity = LocalActivity.current Button( onClick = { diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/userinteractions/UserInteractions.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/userinteractions/UserInteractions.kt index 07248a6f9..12de9f923 100644 --- a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/userinteractions/UserInteractions.kt +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/userinteractions/UserInteractions.kt @@ -30,13 +30,14 @@ import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.LocalRippleConfiguration -import androidx.compose.material.LocalUseFallbackRippleImplementation import androidx.compose.material.RippleConfiguration import androidx.compose.material.ripple import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material.ripple.RippleAlpha import androidx.compose.material.ripple.RippleTheme import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalUseFallbackRippleImplementation import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -239,7 +240,7 @@ private class ScaleIndicationNode( fun App() { } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun LocalUseFallbackRippleImplementationExample() { // [START android_compose_userinteractions_localusefallbackrippleimplementation] @@ -252,7 +253,7 @@ private fun LocalUseFallbackRippleImplementationExample() { } // [START android_compose_userinteractions_localusefallbackrippleimplementation_app_theme] -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MyAppTheme(content: @Composable () -> Unit) { CompositionLocalProvider(LocalUseFallbackRippleImplementation provides true) { diff --git a/compose/snippets/src/main/res/drawable-nodpi/cheese_1.jpg b/compose/snippets/src/main/res/drawable-nodpi/cheese_1.jpg new file mode 100644 index 000000000..65be51c6b Binary files /dev/null and b/compose/snippets/src/main/res/drawable-nodpi/cheese_1.jpg differ diff --git a/compose/snippets/src/main/res/drawable-nodpi/cheese_2.jpg b/compose/snippets/src/main/res/drawable-nodpi/cheese_2.jpg new file mode 100644 index 000000000..ae83e1ac6 Binary files /dev/null and b/compose/snippets/src/main/res/drawable-nodpi/cheese_2.jpg differ diff --git a/compose/snippets/src/main/res/drawable-nodpi/cheese_3.jpg b/compose/snippets/src/main/res/drawable-nodpi/cheese_3.jpg new file mode 100644 index 000000000..362ef3874 Binary files /dev/null and b/compose/snippets/src/main/res/drawable-nodpi/cheese_3.jpg differ diff --git a/compose/snippets/src/main/res/drawable-nodpi/cheese_4.jpg b/compose/snippets/src/main/res/drawable-nodpi/cheese_4.jpg new file mode 100644 index 000000000..052ef4c72 Binary files /dev/null and b/compose/snippets/src/main/res/drawable-nodpi/cheese_4.jpg differ diff --git a/compose/snippets/src/main/res/drawable-nodpi/cheese_5.jpg b/compose/snippets/src/main/res/drawable-nodpi/cheese_5.jpg new file mode 100644 index 000000000..137a7f3a1 Binary files /dev/null and b/compose/snippets/src/main/res/drawable-nodpi/cheese_5.jpg differ diff --git a/compose/snippets/src/main/res/drawable/fast_forward.xml b/compose/snippets/src/main/res/drawable/fast_forward.xml new file mode 100644 index 000000000..d49dffbf3 --- /dev/null +++ b/compose/snippets/src/main/res/drawable/fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/drawable/fast_forward_filled.xml b/compose/snippets/src/main/res/drawable/fast_forward_filled.xml new file mode 100644 index 000000000..2986028f5 --- /dev/null +++ b/compose/snippets/src/main/res/drawable/fast_forward_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/drawable/fast_rewind.xml b/compose/snippets/src/main/res/drawable/fast_rewind.xml new file mode 100644 index 000000000..aec6e80d9 --- /dev/null +++ b/compose/snippets/src/main/res/drawable/fast_rewind.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/drawable/fast_rewind_filled.xml b/compose/snippets/src/main/res/drawable/fast_rewind_filled.xml new file mode 100644 index 000000000..e9426630e --- /dev/null +++ b/compose/snippets/src/main/res/drawable/fast_rewind_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/drawable/favorite.xml b/compose/snippets/src/main/res/drawable/favorite.xml new file mode 100644 index 000000000..f9256d68d --- /dev/null +++ b/compose/snippets/src/main/res/drawable/favorite.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/drawable/favorite_filled.xml b/compose/snippets/src/main/res/drawable/favorite_filled.xml new file mode 100644 index 000000000..1e1136d7b --- /dev/null +++ b/compose/snippets/src/main/res/drawable/favorite_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/compose/snippets/src/main/res/values/strings.xml b/compose/snippets/src/main/res/values/strings.xml index faf8fd472..02254e29a 100644 --- a/compose/snippets/src/main/res/values/strings.xml +++ b/compose/snippets/src/main/res/values/strings.xml @@ -53,4 +53,6 @@ Shopping Profile This is just a placeholder. + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 2dedea847..24e350250 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Mon May 22 14:59:56 BST 2023 -org.gradle.jvmargs=-Xmx2048m +org.gradle.jvmargs=-Xmx4g # Turn on parallel compilation, caching and on-demand configuration org.gradle.configureondemand=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c83d9d77..77f80c6d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,74 +1,92 @@ [versions] accompanist = "0.36.0" -androidGradlePlugin = "8.7.2" -androidx-activity-compose = "1.9.3" +activityKtx = "1.10.1" +android-googleid = "1.1.1" +androidGradlePlugin = "8.12.1" +androidx-activity-compose = "1.10.1" androidx-appcompat = "1.7.0" -androidx-compose-bom = "2024.10.01" +androidx-compose-bom = "2025.08.00" androidx-compose-ui-test = "1.7.0-alpha08" -androidx-constraintlayout = "2.2.0" -androidx-constraintlayout-compose = "1.1.0" -androidx-coordinator-layout = "1.2.0" -androidx-corektx = "1.15.0" +androidx-compose-ui-test-junit4-accessibility = "1.10.0-alpha01" +androidx-constraintlayout = "2.2.1" +androidx-constraintlayout-compose = "1.1.1" +androidx-coordinator-layout = "1.3.0" +androidx-corektx = "1.17.0" +androidx-credentials = "1.5.0" +androidx-credentials-play-services-auth = "1.5.0" androidx-emoji2-views = "1.5.0" -androidx-fragment-ktx = "1.8.5" +androidx-fragment-ktx = "1.8.9" androidx-glance-appwidget = "1.1.1" -androidx-lifecycle-compose = "2.8.7" -androidx-lifecycle-runtime-compose = "2.8.7" -androidx-navigation = "2.8.3" -androidx-paging = "3.3.2" -androidx-test = "1.6.1" -androidx-test-espresso = "3.6.1" -androidx-window = "1.3.0" +androidx-lifecycle-compose = "2.9.2" +androidx-lifecycle-runtime-compose = "2.9.2" +androidx-navigation = "2.9.3" +androidx-paging = "3.3.6" +androidx-startup-runtime = "1.2.0" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-junit = "1.3.0" +androidx-window = "1.5.0-beta02" +androidx-window-core = "1.5.0-beta02" +androidx-window-java = "1.5.0-beta02" +androidx-xr-arcore = "1.0.0-alpha05" +androidx-xr-compose = "1.0.0-alpha06" +androidx-xr-scenecore = "1.0.0-alpha06" androidxHiltNavigationCompose = "1.2.0" +appcompat = "1.7.1" coil = "2.7.0" # @keep -compileSdk = "35" -compose-latest = "1.7.5" -composeUiTooling = "1.4.0" +compileSdk = "36" +compose-latest = "1.9.0" +composeUiTooling = "1.4.1" coreSplashscreen = "1.0.1" -coroutines = "1.9.0" +coroutines = "1.10.2" glide = "1.0.0-beta01" -google-maps = "19.0.0" -gradle-versions = "0.51.0" -guava = "33.2.1-android" -hilt = "2.52" -horologist = "0.6.20" +google-maps = "19.2.0" +gradle-versions = "0.52.0" +guava = "33.4.8-jre" +hilt = "2.57" +horologist = "0.8.1-alpha" junit = "4.13.2" -kotlin = "2.0.21" -kotlinxSerializationJson = "1.7.3" -ksp = "2.0.21-1.0.26" -maps-compose = "6.2.0" -material = "1.13.0-alpha07" -material3-adaptive = "1.0.0" -material3-adaptive-navigation-suite = "1.3.1" -media3 = "1.4.1" +kotlin = "2.2.10" +kotlinCoroutinesOkhttp = "1.0" +kotlinxCoroutinesGuava = "1.10.2" +kotlinxSerializationJson = "1.9.0" +ksp = "2.2.10-2.0.2" +lifecycleService = "2.9.2" +maps-compose = "6.7.2" +material = "1.14.0-alpha03" +material3-adaptive = "1.1.0" +material3-adaptive-navigation-suite = "1.3.2" +media3 = "1.8.0" # @keep -minSdk = "21" -playServicesWearable = "18.2.0" -protolayout = "1.3.0-alpha04" -protolayoutExpression = "1.3.0-alpha04" -protolayoutMaterial = "1.3.0-alpha04" -recyclerview = "1.3.2" -# @keep -targetSdk = "34" -tiles = "1.5.0-alpha04" -tilesRenderer = "1.5.0-alpha04" -tilesTesting = "1.5.0-alpha04" -tilesTooling = "1.5.0-alpha04" -tilesToolingPreview = "1.5.0-alpha04" -version-catalog-update = "0.8.5" +minSdk = "35" +okHttp = "5.1.0" +playServicesWearable = "19.0.0" +protolayout = "1.3.0" +recyclerview = "1.4.0" +targetSdk = "35" +tiles = "1.5.0" +tracing = "1.3.0" +validatorPush = "1.0.0-alpha06" +version-catalog-update = "1.0.0" +watchfaceComplicationsDataSourceKtx = "1.2.1" wear = "1.3.0" -wearComposeFoundation = "1.4.0" -wearComposeMaterial = "1.4.0" +wearComposeFoundation = "1.5.0-rc02" +wearComposeMaterial = "1.5.0-rc02" +wearComposeMaterial3 = "1.5.0-rc02" +wearOngoing = "1.0.0" wearToolingPreview = "1.0.0" +webkit = "1.14.0" [libraries] -accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } -accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +accompanist-adaptive = "com.google.accompanist:accompanist-adaptive:0.37.3" +accompanist-permissions = "com.google.accompanist:accompanist-permissions:0.37.3" accompanist-theme-adapter-appcompat = { module = "com.google.accompanist:accompanist-themeadapter-appcompat", version.ref = "accompanist" } accompanist-theme-adapter-material = { module = "com.google.accompanist:accompanist-themeadapter-material", version.ref = "accompanist" } accompanist-theme-adapter-material3 = { module = "com.google.accompanist:accompanist-themeadapter-material3", version.ref = "accompanist" } +android-identity-googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "android-googleid" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose-latest" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } @@ -87,8 +105,9 @@ androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-latest" } androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } -androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "compose-latest" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-latest" } +androidx-compose-ui-test-junit4-accessibility = { module = "androidx.compose.ui:ui-test-junit4-accessibility", version.ref = "androidx-compose-ui-test-junit4-accessibility" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -99,6 +118,8 @@ androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constr androidx-coordinator-layout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidx-coordinator-layout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials-play-services-auth" } androidx-emoji2-views = { module = "androidx.emoji2:emoji2-views", version.ref = "androidx-emoji2-views" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment-ktx" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance-appwidget" } @@ -107,6 +128,7 @@ androidx-graphics-shapes = "androidx.graphics:graphics-shapes:1.0.1" androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-compose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleService" } androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } @@ -115,24 +137,36 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } androidx-protolayout = { module = "androidx.wear.protolayout:protolayout", version.ref = "protolayout" } -androidx-protolayout-expression = { module = "androidx.wear.protolayout:protolayout-expression", version.ref = "protolayoutExpression" } -androidx-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material", version.ref = "protolayoutMaterial" } +androidx-protolayout-expression = { module = "androidx.wear.protolayout:protolayout-expression", version.ref = "protolayout" } +androidx-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material", version.ref = "protolayout" } +androidx-protolayout-material3 = { module = "androidx.wear.protolayout:protolayout-material3", version.ref = "protolayout" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup-runtime" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } -androidx-test-runner = "androidx.test:runner:1.6.2" +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "tiles" } -androidx-tiles-renderer = { module = "androidx.wear.tiles:tiles-renderer", version.ref = "tilesRenderer" } -androidx-tiles-testing = { module = "androidx.wear.tiles:tiles-testing", version.ref = "tilesTesting" } -androidx-tiles-tooling = { module = "androidx.wear.tiles:tiles-tooling", version.ref = "tilesTooling" } -androidx-tiles-tooling-preview = { module = "androidx.wear.tiles:tiles-tooling-preview", version.ref = "tilesToolingPreview" } +androidx-tiles-renderer = { module = "androidx.wear.tiles:tiles-renderer", version.ref = "tiles" } +androidx-tiles-testing = { module = "androidx.wear.tiles:tiles-testing", version.ref = "tiles" } +androidx-tiles-tooling = { module = "androidx.wear.tiles:tiles-tooling", version.ref = "tiles" } +androidx-tiles-tooling-preview = { module = "androidx.wear.tiles:tiles-tooling-preview", version.ref = "tiles" } +androidx-tracing = { module = "androidx.tracing:tracing", version.ref = "tracing" } +androidx-watchface-complications-data-source-ktx = { module = "androidx.wear.watchface:watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" } +androidx-wear-ongoing = { module = "androidx.wear:wear-ongoing", version.ref = "wearOngoing" } androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wearToolingPreview" } -androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } -androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.10.0" +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window-core" } +androidx-window-java = { module = "androidx.window:window-java", version.ref = "androidx-window-java" } +androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.10.3" +androidx-xr-arcore = { module = "androidx.xr.arcore:arcore", version.ref = "androidx-xr-arcore" } +androidx-xr-compose = { module = "androidx.xr.compose:compose", version.ref = "androidx-xr-compose" } +androidx-xr-scenecore = { module = "androidx.xr.scenecore:scenecore", version.ref = "androidx-xr-scenecore" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wearComposeFoundation" } -compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "composeUiTooling" } glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide" } google-android-material = { module = "com.google.android.material:material", version.ref = "material" } @@ -144,11 +178,17 @@ hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.re horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } junit = { module = "junit:junit", version.ref = "junit" } +kotlin-coroutines-okhttp = { module = "ru.gildor.coroutines:kotlin-coroutines-okhttp", version.ref = "kotlinCoroutinesOkhttp" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuava" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttp" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } +validator-push = { module = "com.google.android.wearable.watchface.validator:validator-push", version.ref = "validatorPush" } +wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } +wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -156,7 +196,7 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-android = "org.jetbrains.kotlin.android:2.2.10" kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0aaefbcaf..37f853b1c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a426..983fb2e97 100755 --- a/gradlew +++ b/gradlew @@ -205,7 +205,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# * For validator: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ diff --git a/identity/credentialmanager/.gitignore b/identity/credentialmanager/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/identity/credentialmanager/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/identity/credentialmanager/build.gradle.kts b/identity/credentialmanager/build.gradle.kts new file mode 100644 index 000000000..2e35608a7 --- /dev/null +++ b/identity/credentialmanager/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + alias(libs.plugins.android.application) + // [START android_identity_fido2_migration_dependency] + alias(libs.plugins.kotlin.android) + // [END android_identity_fido2_migration_dependency] + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.example.identity.credentialmanager" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.example.identity.credentialmanager" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + sourceSets { + named("main") { + java { + srcDir("src/main/java") + } + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + + // [START android_identity_credman_dependency] + implementation(libs.androidx.credentials) + // [END android_identity_credman_dependency] + + // [START android_identity_gradle_dependencies] + implementation(libs.androidx.credentials) + + // optional - needed for credentials support from play services, for devices running + // Android 13 and below. + implementation(libs.androidx.credentials.play.services.auth) + // [END android_identity_gradle_dependencies] + // [START android_identity_siwg_gradle_dependencies] + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services.auth) + implementation(libs.android.identity.googleid) + // [END android_identity_siwg_gradle_dependencies] + implementation(libs.okhttp) + implementation(libs.kotlin.coroutines.okhttp) + implementation(libs.androidx.webkit) + implementation(libs.appcompat) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/identity/credentialmanager/proguard-rules.pro b/identity/credentialmanager/proguard-rules.pro new file mode 100644 index 000000000..6075f01e2 --- /dev/null +++ b/identity/credentialmanager/proguard-rules.pro @@ -0,0 +1,28 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# // [START android_identity_proguard_rules] +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} +# // [END android_identity_proguard_rules] \ No newline at end of file diff --git a/identity/credentialmanager/src/main/AndroidManifest.xml b/identity/credentialmanager/src/main/AndroidManifest.xml new file mode 100644 index 000000000..fb060cc44 --- /dev/null +++ b/identity/credentialmanager/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/identity/credentialmanager/src/main/digitalAssetLinking/assetlinks.json b/identity/credentialmanager/src/main/digitalAssetLinking/assetlinks.json new file mode 100644 index 000000000..34168d435 --- /dev/null +++ b/identity/credentialmanager/src/main/digitalAssetLinking/assetlinks.json @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 + * + * https://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. + */ + +// [START android_identity_assetlinks_json] +[ + { + "relation" : [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target" : { + "namespace" : "android_app", + "package_name" : "com.example.android", + "sha256_cert_fingerprints" : [ + SHA_HEX_VALUE + ] + } + } +] +// [END android_identity_assetlinks_json] \ No newline at end of file diff --git a/identity/credentialmanager/src/main/digitalAssetLinking/othersnippets b/identity/credentialmanager/src/main/digitalAssetLinking/othersnippets new file mode 100644 index 000000000..66ea809a8 --- /dev/null +++ b/identity/credentialmanager/src/main/digitalAssetLinking/othersnippets @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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 + * + * https://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. + */ + +// Enable host to permit Google to retrieve the DAL +// [START android_identity_assetlinks_allow_host] +User-agent: * +Allow: /.well-known/ +// [END android_identity_assetlinks_allow_host] + +// Manifest file addition +// [START android_identity_assetlinks_manifest] + +// [END android_identity_assetlinks_manifest] + +// Declare association in Android app +// [START android_identity_assetlinks_app_association] + +[{ + \"include\": \"https://signin.example.com/.well-known/assetlinks.json/" +}] + +// [END android_identity_assetlinks_app_association] + +// Example status code to test DAL +// [START android_identity_assetlinks_curl_check] +> GET /.well-known/assetlinks.json HTTP/1.1 +> User-Agent: curl/7.35.0 +> Host: signin.example.com + +< HTTP/1.1 200 OK +< Content-Type: application/json +// [END android_identity_assetlinks_curl_check] \ No newline at end of file diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt new file mode 100644 index 000000000..381bc8fc3 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialManagerHandler.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.app.Activity +import android.util.Log +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException + +// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/CredentialManagerHandler.kt. +class CredentialManagerHandler(private val activity: Activity) { + private val mCredMan = CredentialManager.create(activity.applicationContext) + private val TAG = "CredentialManagerHandler" + /** + * Encapsulates the create passkey API for credential manager in a less error-prone manner. + * + * @param request a create public key credential request JSON required by [CreatePublicKeyCredentialRequest]. + * @return [CreatePublicKeyCredentialResponse] containing the result of the credential creation. + */ + suspend fun createPasskey(request: String): CreatePublicKeyCredentialResponse { + val createRequest = CreatePublicKeyCredentialRequest(request) + try { + return mCredMan.createCredential(activity, createRequest) as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys + Log.i(TAG, "Error creating credential: ErrMessage: ${e.errorMessage}, ErrType: ${e.type}") + throw e + } + } + + /** + * Encapsulates the get passkey API for credential manager in a less error-prone manner. + * + * @param request a get public key credential request JSON required by [GetCredentialRequest]. + * @return [GetCredentialResponse] containing the result of the credential retrieval. + */ + suspend fun getPasskey(request: String): GetCredentialResponse { + val getRequest = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request, null))) + try { + return mCredMan.getCredential(activity, getRequest) + } catch (e: GetCredentialException) { + // For error handling use guidance from https://developer.android.com/training/sign-in/passkeys + Log.i(TAG, "Error retrieving credential: ${e.message}") + throw e + } + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialProviderDummyActivity.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialProviderDummyActivity.kt new file mode 100644 index 000000000..ed65bd831 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/CredentialProviderDummyActivity.kt @@ -0,0 +1,506 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.os.Build.VERSION_CODES +import android.os.Bundle +import android.os.PersistableBundle +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.AuthenticationCallback +import androidx.biometric.BiometricPrompt.AuthenticationResult +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.webauthn.AuthenticatorAssertionResponse +import androidx.credentials.webauthn.AuthenticatorAttestationResponse +import androidx.credentials.webauthn.FidoPublicKeyCredential +import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions +import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions +import androidx.fragment.app.FragmentActivity +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPrivateKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.EllipticCurve + +class CredentialProviderDummyActivity : FragmentActivity() { + + private val PERSONAL_ACCOUNT_ID: String = "" + private val FAMILY_ACCOUNT_ID: String = "" + private val CREATE_PASSWORD_INTENT: String = "" + + @RequiresApi(VERSION_CODES.M) + // [START android_identity_credential_provider_handle_passkey] + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + // ... + + val request = + PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + + val accountId = intent.getStringExtra(CredentialsRepo.EXTRA_KEY_ACCOUNT_ID) + if (request != null && request.callingRequest is CreatePublicKeyCredentialRequest) { + val publicKeyRequest: CreatePublicKeyCredentialRequest = + request.callingRequest as CreatePublicKeyCredentialRequest + createPasskey( + publicKeyRequest.requestJson, + request.callingAppInfo, + publicKeyRequest.clientDataHash, + accountId + ) + } + } + + @SuppressLint("RestrictedApi") + fun createPasskey( + requestJson: String, + callingAppInfo: CallingAppInfo?, + clientDataHash: ByteArray?, + accountId: String? + ) { + val request = PublicKeyCredentialCreationOptions(requestJson) + + val biometricPrompt = BiometricPrompt( + this, + { }, // Pass in your own executor + object : AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + finish() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + finish() + } + + @RequiresApi(VERSION_CODES.P) + override fun onAuthenticationSucceeded( + result: AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + + // Generate a credentialId + val credentialId = ByteArray(32) + SecureRandom().nextBytes(credentialId) + + // Generate a credential key pair + val spec = ECGenParameterSpec("secp256r1") + val keyPairGen = KeyPairGenerator.getInstance("EC") + keyPairGen.initialize(spec) + val keyPair = keyPairGen.genKeyPair() + + // Save passkey in your database as per your own implementation + + // Create AuthenticatorAttestationResponse object to pass to + // FidoPublicKeyCredential + + val response = AuthenticatorAttestationResponse( + requestOptions = request, + credentialId = credentialId, + credentialPublicKey = getPublicKeyFromKeyPair(keyPair), + origin = appInfoToOrigin(callingAppInfo!!), + up = true, + uv = true, + be = true, + bs = true, + packageName = callingAppInfo.packageName + ) + + val credential = FidoPublicKeyCredential( + rawId = credentialId, + response = response, + authenticatorAttachment = "", // Add your authenticator attachment + ) + val result = Intent() + + val createPublicKeyCredResponse = + CreatePublicKeyCredentialResponse(credential.json()) + + // Set the CreateCredentialResponse as the result of the Activity + PendingIntentHandler.setCreateCredentialResponse( + result, + createPublicKeyCredResponse + ) + setResult(RESULT_OK, result) + finish() + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Use your screen lock") + .setSubtitle("Create passkey for ${request.rp.name}") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + /* or BiometricManager.Authenticators.DEVICE_CREDENTIAL */ + ) + .build() + biometricPrompt.authenticate(promptInfo) + } + + @RequiresApi(VERSION_CODES.P) + fun appInfoToOrigin(info: CallingAppInfo): String { + val cert = info.signingInfo.apkContentsSigners[0].toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val certHash = md.digest(cert) + // This is the format for origin + return "android:apk-key-hash:${b64Encode(certHash)}" + } + // [END android_identity_credential_provider_handle_passkey] + + @RequiresApi(VERSION_CODES.M) + // [START android_identity_credential_provider_password_creation] + fun processCreateCredentialRequest( + request: BeginCreateCredentialRequest + ): BeginCreateCredentialResponse? { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + return handleCreatePasskeyQuery(request) + } + + is BeginCreatePasswordCredentialRequest -> { + // Request is password type + return handleCreatePasswordQuery(request) + } + } + return null + } + + @RequiresApi(VERSION_CODES.M) + private fun handleCreatePasswordQuery( + request: BeginCreatePasswordCredentialRequest + ): BeginCreateCredentialResponse { + val createEntries: MutableList = mutableListOf() + + // Adding two create entries - one for storing credentials to the 'Personal' + // account, and one for storing them to the 'Family' account. These + // accounts are local to this sample app only. + createEntries.add( + CreateEntry( + PERSONAL_ACCOUNT_ID, + createNewPendingIntent(PERSONAL_ACCOUNT_ID, CREATE_PASSWORD_INTENT) + ) + ) + createEntries.add( + CreateEntry( + FAMILY_ACCOUNT_ID, + createNewPendingIntent(FAMILY_ACCOUNT_ID, CREATE_PASSWORD_INTENT) + ) + ) + + return BeginCreateCredentialResponse(createEntries) + } + // [END android_identity_credential_provider_password_creation] + + @RequiresApi(VERSION_CODES.M) + fun handleEntrySelectionForPasswordCreation( + mDatabase: MyDatabase + ) { + // [START android_identity_credential_provider_entry_selection_password_creation] + val createRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + val accountId = intent.getStringExtra(CredentialsRepo.EXTRA_KEY_ACCOUNT_ID) + + if (createRequest == null) { + return + } + + val request: CreatePasswordRequest = createRequest.callingRequest as CreatePasswordRequest + + // Fetch the ID and password from the request and save it in your database + mDatabase.addNewPassword( + PasswordInfo( + request.id, + request.password, + createRequest.callingAppInfo.packageName + ) + ) + + // Set the final response back + val result = Intent() + val response = CreatePasswordResponse() + PendingIntentHandler.setCreateCredentialResponse(result, response) + setResult(Activity.RESULT_OK, result) + finish() + // [END android_identity_credential_provider_entry_selection_password_creation] + } + + @RequiresApi(VERSION_CODES.P) + private fun handleUserSelectionForPasskeys( + mDatabase: MyDatabase + ) { + // [START android_identity_credential_provider_user_pk_selection] + val getRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + val publicKeyRequest = getRequest?.credentialOptions?.first() as GetPublicKeyCredentialOption + + val requestInfo = intent.getBundleExtra("CREDENTIAL_DATA") + val credIdEnc = requestInfo?.getString("credId").orEmpty() + + // Get the saved passkey from your database based on the credential ID from the PublicKeyRequest + val passkey = mDatabase.getPasskey(credIdEnc) + + // Decode the credential ID, private key and user ID + val credId = b64Decode(credIdEnc) + val privateKey = b64Decode(passkey.credPrivateKey) + val uid = b64Decode(passkey.uid) + + val origin = appInfoToOrigin(getRequest.callingAppInfo) + val packageName = getRequest.callingAppInfo.packageName + + validatePasskey( + publicKeyRequest.requestJson, + origin, + packageName, + uid, + passkey.username, + credId, + privateKey + ) + // [END android_identity_credential_provider_user_pk_selection] + } + + @SuppressLint("RestrictedApi") + @RequiresApi(VERSION_CODES.M) + private fun validatePasskey( + requestJson: String, + origin: String, + packageName: String, + uid: ByteArray, + username: String, + credId: ByteArray, + privateKeyBytes: ByteArray, + ) { + // [START android_identity_credential_provider_user_validation_biometric] + val request = PublicKeyCredentialRequestOptions(requestJson) + val privateKey: ECPrivateKey = convertPrivateKey(privateKeyBytes) + + val biometricPrompt = BiometricPrompt( + this, + { }, // Pass in your own executor + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errorCode, errString) + finish() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + finish() + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + val response = AuthenticatorAssertionResponse( + requestOptions = request, + credentialId = credId, + origin = origin, + up = true, + uv = true, + be = true, + bs = true, + userHandle = uid, + packageName = packageName + ) + + val sig = Signature.getInstance("SHA256withECDSA") + sig.initSign(privateKey) + sig.update(response.dataToSign()) + response.signature = sig.sign() + + val credential = FidoPublicKeyCredential( + rawId = credId, + response = response, + authenticatorAttachment = "", // Add your authenticator attachment + ) + val result = Intent() + val passkeyCredential = PublicKeyCredential(credential.json()) + PendingIntentHandler.setGetCredentialResponse( + result, GetCredentialResponse(passkeyCredential) + ) + setResult(RESULT_OK, result) + finish() + } + } + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Use your screen lock") + .setSubtitle("Use passkey for ${request.rpId}") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + /* or BiometricManager.Authenticators.DEVICE_CREDENTIAL */ + ) + .build() + biometricPrompt.authenticate(promptInfo) + // [END android_identity_credential_provider_user_validation_biometric] + } + + @RequiresApi(VERSION_CODES.M) + private fun handleUserSelectionForPasswordAuthentication( + mDatabase: MyDatabase, + callingAppInfo: CallingAppInfo, + ) { + // [START android_identity_credential_provider_user_selection_password] + val getRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + + val passwordOption = getRequest?.credentialOptions?.first() as GetPasswordOption + + val username = passwordOption.allowedUserIds.first() + // Fetch the credentials for the calling app package name + val creds = mDatabase.getCredentials(callingAppInfo.packageName) + val passwords = creds.passwords + val it = passwords.iterator() + var password = "" + while (it.hasNext()) { + val passwordItemCurrent = it.next() + if (passwordItemCurrent.username == username) { + password = passwordItemCurrent.password + break + } + } + // [END android_identity_credential_provider_user_selection_password] + + // [START android_identity_credential_provider_set_response] + // Set the response back + val result = Intent() + val passwordCredential = PasswordCredential(username, password) + PendingIntentHandler.setGetCredentialResponse( + result, GetCredentialResponse(passwordCredential) + ) + setResult(Activity.RESULT_OK, result) + finish() + // [END android_identity_credential_provider_set_response] + } + + // [START android_identity_credential_pending_intent] + fun createSettingsPendingIntent(): PendingIntent { // [END android_identity_credential_pending_intent] + return PendingIntent.getBroadcast(this, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + } + + private fun getPublicKeyFromKeyPair(keyPair: KeyPair): ByteArray { + return byteArrayOf() + } + + private fun b64Encode(certHash: ByteArray) {} + + private fun handleCreatePasskeyQuery( + request: BeginCreatePublicKeyCredentialRequest + ): BeginCreateCredentialResponse { + return BeginCreateCredentialResponse() + } + + private fun createNewPendingIntent( + accountId: String, + intent: String + ): PendingIntent { + return PendingIntent.getBroadcast(this, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + } + + private fun b64Decode(encodedString: String): ByteArray { + return byteArrayOf() + } + + private fun convertPrivateKey(privateKeyBytes: ByteArray): ECPrivateKey { + return ECPrivateKeyImpl() + } +} + +object CredentialsRepo { + const val EXTRA_KEY_ACCOUNT_ID: String = "" +} + +class MyDatabase { + fun addNewPassword(passwordInfo: PasswordInfo) {} + + fun getPasskey(credIdEnc: String): PasskeyInfo { + return PasskeyInfo() + } + + fun getCredentials(packageName: String): CredentialsInfo { + return CredentialsInfo() + } +} + +data class PasswordInfo( + val id: String = "", + val password: String = "", + val packageName: String = "", + val username: String = "" +) + +data class PasskeyInfo( + val credPrivateKey: String = "", + val uid: String = "", + val username: String = "" +) + +data class CredentialsInfo( + val passwords: List = listOf() +) + +class ECPrivateKeyImpl : ECPrivateKey { + override fun getAlgorithm(): String = "" + override fun getFormat(): String = "" + override fun getEncoded(): ByteArray = byteArrayOf() + override fun getParams(): ECParameterSpec { + return ECParameterSpec( + EllipticCurve( + { 0 }, + BigInteger.ZERO, + BigInteger.ZERO + ), + ECPoint( + BigInteger.ZERO, + BigInteger.ZERO + ), + BigInteger.ZERO, + 0 + ) + } + override fun getS(): BigInteger = BigInteger.ZERO +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt new file mode 100644 index 000000000..05b06cce7 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/Fido2ToCredmanMigration.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.app.Activity +import android.content.Context +import android.util.JsonWriter +import android.util.Log +import android.widget.Toast +import androidx.credentials.CreateCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import com.example.identity.credentialmanager.ApiResult.Success +import java.io.StringWriter +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request.Builder +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import org.json.JSONObject +import ru.gildor.coroutines.okhttp.await + +class Fido2ToCredmanMigration( + private val context: Context, + private val client: OkHttpClient, +) { + private val BASE_URL = "" + private val JSON = "".toMediaTypeOrNull() + private val PUBLIC_KEY = "" + + // [START android_identity_fido2_credman_init] + val credMan = CredentialManager.create(context) + // [END android_identity_fido2_credman_init] + + // [START android_identity_fido2_migration_post_request_body] + suspend fun registerRequest() { + // ... + val call = client.newCall( + Builder() + .method( + "POST", + jsonRequestBody { + name("attestation").value("none") + name("authenticatorSelection").objectValue { + name("residentKey").value("required") + } + } + ).build() + ) + // ... + } + // [END android_identity_fido2_migration_post_request_body] + + // [START android_identity_fido2_migration_register_request] + suspend fun registerRequest(sessionId: String): ApiResult { + val call = client.newCall( + Builder() + .url("https://github.com/$BASE_URL/%3Cyour%20api%20url%3E") + .addHeader("Cookie", formatCookie(sessionId)) + .method( + "POST", + jsonRequestBody { + name("attestation").value("none") + name("authenticatorSelection").objectValue { + name("authenticatorAttachment").value("platform") + name("userVerification").value("required") + name("residentKey").value("required") + } + } + ).build() + ) + val response = call.await() + return response.result("Error calling the api") { + parsePublicKeyCredentialCreationOptions( + body ?: throw ApiException("Empty response from the api call") + ) + } + } + // [END android_identity_fido2_migration_register_request] + + // [START android_identity_fido2_migration_create_passkey] + suspend fun createPasskey( + activity: Activity, + requestResult: JSONObject + ): CreatePublicKeyCredentialResponse? { + val request = CreatePublicKeyCredentialRequest(requestResult.toString()) + var response: CreatePublicKeyCredentialResponse? = null + try { + response = credMan.createCredential( + request = request as CreateCredentialRequest, + context = activity + ) as CreatePublicKeyCredentialResponse + } catch (e: CreateCredentialException) { + + showErrorAlert(activity, e) + + return null + } + return response + } + // [END android_identity_fido2_migration_create_passkey] + + // [START android_identity_fido2_migration_auth_with_passkeys] + /** + * @param sessionId The session ID to be used for the sign-in. + * @param credentialId The credential ID of this device. + * @return a JSON object. + */ + suspend fun signinRequest(): ApiResult { + val call = client.newCall( + Builder().url( + buildString { + append("$BASE_URL/signinRequest") + } + ).method("POST", jsonRequestBody {}) + .build() + ) + val response = call.await() + return response.result("Error calling /signinRequest") { + parsePublicKeyCredentialRequestOptions( + body ?: throw ApiException("Empty response from /signinRequest") + ) + } + } + + /** + * @param sessionId The session ID to be used for the sign-in. + * @param response The JSONObject for signInResponse. + * @param credentialId id/rawId. + * @return A list of all the credentials registered on the server, + * including the newly-registered one. + */ + suspend fun signinResponse( + sessionId: String, + response: JSONObject, + credentialId: String + ): ApiResult { + + val call = client.newCall( + Builder().url("https://github.com/$BASE_URL/signinResponse") + .addHeader("Cookie", formatCookie(sessionId)) + .method( + "POST", + jsonRequestBody { + name("id").value(credentialId) + name("type").value(PUBLIC_KEY.toString()) + name("rawId").value(credentialId) + name("response").objectValue { + name("clientDataJSON").value( + response.getString("clientDataJSON") + ) + name("authenticatorData").value( + response.getString("authenticatorData") + ) + name("signature").value( + response.getString("signature") + ) + name("userHandle").value( + response.getString("userHandle") + ) + } + } + ).build() + ) + val apiResponse = call.await() + return apiResponse.result("Error calling /signingResponse") { + } + } + // [END android_identity_fido2_migration_auth_with_passkeys] + + // [START android_identity_fido2_migration_get_passkeys] + suspend fun getPasskey( + activity: Activity, + creationResult: JSONObject + ): GetCredentialResponse? { + Toast.makeText( + activity, + "Fetching previously stored credentials", + Toast.LENGTH_SHORT + ) + .show() + var result: GetCredentialResponse? = null + try { + val request = GetCredentialRequest( + listOf( + GetPublicKeyCredentialOption( + creationResult.toString(), + null + ), + GetPasswordOption() + ) + ) + result = credMan.getCredential(activity, request) + if (result.credential is PublicKeyCredential) { + val publicKeycredential = result.credential as PublicKeyCredential + Log.i("TAG", "Passkey ${publicKeycredential.authenticationResponseJson}") + return result + } + } catch (e: Exception) { + showErrorAlert(activity, e) + } + return result + } + // [END android_identity_fido2_migration_get_passkeys] + + private fun showErrorAlert( + activity: Activity, + e: Exception + ) {} + + private fun jsonRequestBody(body: JsonWriter.() -> Unit): RequestBody { + val output = StringWriter() + JsonWriter(output).use { writer -> + writer.beginObject() + writer.body() + writer.endObject() + } + return output.toString().toRequestBody(JSON) + } + + private fun JsonWriter.objectValue(body: JsonWriter.() -> Unit) { + beginObject() + body() + endObject() + } + + private fun formatCookie(sessionId: String): String { + return "" + } + + private fun parsePublicKeyCredentialCreationOptions(body: ResponseBody): JSONObject { + return JSONObject() + } + + private fun parsePublicKeyCredentialRequestOptions(body: ResponseBody): JSONObject { + return JSONObject() + } + + private fun Response.result(errorMessage: String, data: Response.() -> T): ApiResult { + return Success() + } +} + +sealed class ApiResult { + class Success : ApiResult() +} + +class ApiException(message: String) : RuntimeException(message) diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MainActivity.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MainActivity.kt new file mode 100644 index 000000000..58fae77dc --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MainActivity.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.os.Bundle +import androidx.activity.ComponentActivity + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MyCredentialProviderService.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MyCredentialProviderService.kt new file mode 100644 index 000000000..77db763d3 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/MyCredentialProviderService.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build.VERSION_CODES +import android.os.Bundle +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.AuthenticationAction +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.PasswordCredentialEntry +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions + +@RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) +class MyCredentialProviderService : CredentialProviderService() { + private val PERSONAL_ACCOUNT_ID: String = "" + private val FAMILY_ACCOUNT_ID: String = "" + private val CREATE_PASSKEY_INTENT: String = "" + private val PACKAGE_NAME: String = "" + private val EXTRA_KEY_ACCOUNT_ID: String = "" + private val UNIQUE_REQ_CODE: Int = 1 + private val UNLOCK_INTENT: String = "" + private val UNIQUE_REQUEST_CODE: Int = 0 + private val TAG: String = "" + private val GET_PASSWORD_INTENT: String = "" + + // [START android_identity_credential_provider_passkey_creation] + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request) + if (response != null) { + callback.onResult(response) + } else { + callback.onError(CreateCredentialUnknownException()) + } + } + + fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + return handleCreatePasskeyQuery(request) + } + } + // Request not supported + return null + } + + private fun handleCreatePasskeyQuery( + request: BeginCreatePublicKeyCredentialRequest + ): BeginCreateCredentialResponse { + + // Adding two create entries - one for storing credentials to the 'Personal' + // account, and one for storing them to the 'Family' account. These + // accounts are local to this sample app only. + val createEntries: MutableList = mutableListOf() + createEntries.add( + CreateEntry( + PERSONAL_ACCOUNT_ID, + createNewPendingIntent(PERSONAL_ACCOUNT_ID, CREATE_PASSKEY_INTENT) + ) + ) + + createEntries.add( + CreateEntry( + FAMILY_ACCOUNT_ID, + createNewPendingIntent(FAMILY_ACCOUNT_ID, CREATE_PASSKEY_INTENT) + ) + ) + + return BeginCreateCredentialResponse(createEntries) + } + + private fun createNewPendingIntent(accountId: String, action: String): PendingIntent { + val intent = Intent(action).setPackage(PACKAGE_NAME) + + // Add your local account ID as an extra to the intent, so that when + // user selects this entry, the credential can be saved to this + // account + intent.putExtra(EXTRA_KEY_ACCOUNT_ID, accountId) + + return PendingIntent.getActivity( + applicationContext, UNIQUE_REQ_CODE, + intent, + ( + PendingIntent.FLAG_MUTABLE + or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } + // [END android_identity_credential_provider_passkey_creation] + + private lateinit var response: BeginGetCredentialResponse + + // [START android_identity_credential_provider_sign_in] + private val unlockEntryTitle = "Authenticate to continue" + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + if (isAppLocked()) { + callback.onResult( + BeginGetCredentialResponse( + authenticationActions = mutableListOf( + AuthenticationAction( + unlockEntryTitle, createUnlockPendingIntent() + ) + ) + ) + ) + return + } + try { + response = processGetCredentialRequest(request) + callback.onResult(response) + } catch (e: GetCredentialException) { + callback.onError(GetCredentialUnknownException()) + } + } + // [END android_identity_credential_provider_sign_in] + + // [START android_identity_credential_provider_unlock_pending_intent] + private fun createUnlockPendingIntent(): PendingIntent { + val intent = Intent(UNLOCK_INTENT).setPackage(PACKAGE_NAME) + return PendingIntent.getActivity( + applicationContext, UNIQUE_REQUEST_CODE, intent, + ( + PendingIntent.FLAG_MUTABLE + or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } + // [END android_identity_credential_provider_unlock_pending_intent] + + // [START android_identity_credential_provider_process_get_credential_request] + companion object { + // These intent actions are specified for corresponding activities + // that are to be invoked through the PendingIntent(s) + private const val GET_PASSKEY_INTENT_ACTION = "PACKAGE_NAME.GET_PASSKEY" + private const val GET_PASSWORD_INTENT_ACTION = "PACKAGE_NAME.GET_PASSWORD" + } + + fun processGetCredentialRequest( + request: BeginGetCredentialRequest + ): BeginGetCredentialResponse { + val callingPackageInfo = request.callingAppInfo + val callingPackageName = callingPackageInfo?.packageName.orEmpty() + val credentialEntries: MutableList = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPasswordOption -> { + credentialEntries.addAll( + populatePasswordData( + callingPackageName, + option + ) + ) + } + is BeginGetPublicKeyCredentialOption -> { + credentialEntries.addAll( + populatePasskeyData( + callingPackageInfo, + option + ) + ) + } else -> { + Log.i(TAG, "Request not supported") + } + } + } + return BeginGetCredentialResponse(credentialEntries) + } + // [END android_identity_credential_provider_process_get_credential_request] + + @SuppressLint("RestrictedApi") + // [START android_identity_credential_provider_populate_pkpw_data] + private fun populatePasskeyData( + callingAppInfo: CallingAppInfo?, + option: BeginGetPublicKeyCredentialOption + ): List { + val passkeyEntries: MutableList = mutableListOf() + val request = PublicKeyCredentialRequestOptions(option.requestJson) + // Get your credentials from database where you saved during creation flow + val creds = getCredentialsFromInternalDb(request.rpId) + val passkeys = creds.passkeys + for (passkey in passkeys) { + val data = Bundle() + data.putString("credId", passkey.credId) + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey.username, + pendingIntent = createNewPendingIntent( + GET_PASSKEY_INTENT_ACTION, + data + ), + beginGetPublicKeyCredentialOption = option, + displayName = passkey.displayName, + icon = passkey.icon + ) + ) + } + return passkeyEntries + } + + // Fetch password credentials and create password entries to populate to the user + private fun populatePasswordData( + callingPackage: String, + option: BeginGetPasswordOption + ): List { + val passwordEntries: MutableList = mutableListOf() + + // Get your password credentials from database where you saved during + // creation flow + val creds = getCredentialsFromInternalDb(callingPackage) + val passwords = creds.passwords + for (password in passwords) { + passwordEntries.add( + PasswordCredentialEntry( + context = applicationContext, + username = password.username, + pendingIntent = createNewPendingIntent( + GET_PASSWORD_INTENT + ), + beginGetPasswordOption = option, + displayName = password.username, + icon = password.icon + ) + ) + } + return passwordEntries + } + + private fun createNewPendingIntent( + action: String, + extra: Bundle? = null + ): PendingIntent { + val intent = Intent(action).setPackage(PACKAGE_NAME) + if (extra != null) { + intent.putExtra("CREDENTIAL_DATA", extra) + } + + return PendingIntent.getActivity( + applicationContext, UNIQUE_REQUEST_CODE, intent, + (PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + ) + } + // [END android_identity_credential_provider_populate_pkpw_data] + + // [START android_identity_credential_provider_clear_credential] + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + // Delete any maintained state as appropriate. + } + // [END android_identity_credential_provider_clear_credential] + + private fun isAppLocked(): Boolean { + return true + } + + private fun getCredentialsFromInternalDb(rpId: String): Creds { + return Creds() + } +} + +data class Creds( + val passkeys: List = listOf(), + val passwords: List = listOf() +) + +data class Passkey( + val credId: String = "", + val username: String = "", + val displayName: String = "", + val icon: Icon +) + +data class Password( + val username: String = "", + val icon: Icon +) diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyAndPasswordFunctions.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyAndPasswordFunctions.kt new file mode 100644 index 000000000..747f72c15 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyAndPasswordFunctions.kt @@ -0,0 +1,331 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatEditText +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PasswordCredential +import androidx.credentials.PendingGetCredentialRequest +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialCustomException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialInterruptedException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.pendingGetCredentialRequest +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.json.JSONObject + +class PasskeyAndPasswordFunctions( + context: Context, +) { + // [START android_identity_initialize_credman] + // Use your app or activity context to instantiate a client instance of + // CredentialManager. + private val credentialManager = CredentialManager.create(context) + // [END android_identity_initialize_credman] + private val activityContext = context + + // Placeholder for TAG log value. + val TAG = "" + /** + * Retrieves a passkey from the credential manager. + * + * @param creationResult The result of the passkey creation operation. + * @param context The activity context from the Composable, to be used in Credential Manager APIs + * @return The [GetCredentialResponse] object containing the passkey, or null if an error occurred. + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun signInFlow( + creationResult: JSONObject + ) { + val requestJson = creationResult.toString() + // [START android_identity_get_password_passkey_options] + // Retrieves the user's saved password for your app from their + // password provider. + val getPasswordOption = GetPasswordOption() + + // Get passkey from the user's public key credential provider. + val getPublicKeyCredentialOption = GetPublicKeyCredentialOption( + requestJson = requestJson + ) + // [END android_identity_get_password_passkey_options] + var result: GetCredentialResponse + // [START android_identity_get_credential_request] + val credentialRequest = GetCredentialRequest( + listOf(getPasswordOption, getPublicKeyCredentialOption), + ) + // [END android_identity_get_credential_request] + runBlocking { + // getPrepareCredential request + // [START android_identity_prepare_get_credential] + coroutineScope { + val response = credentialManager.prepareGetCredential( + GetCredentialRequest( + listOf( + getPublicKeyCredentialOption, + getPasswordOption + ) + ) + ) + } + // [END android_identity_prepare_get_credential] + // getCredential request without handling exception. + // [START android_identity_launch_sign_in_flow_1] + coroutineScope { + try { + result = credentialManager.getCredential( + // Use an activity-based context to avoid undefined system UI + // launching behavior. + context = activityContext, + request = credentialRequest + ) + handleSignIn(result) + } catch (e: GetCredentialException) { + // Handle failure + } + } + // [END android_identity_launch_sign_in_flow_1] + // getCredential request adding some exception handling. + // [START android_identity_handle_exceptions_no_credential] + coroutineScope { + try { + result = credentialManager.getCredential( + context = activityContext, + request = credentialRequest + ) + } catch (e: GetCredentialException) { + Log.e("CredentialManager", "No credential available", e) + } + } + // [END android_identity_handle_exceptions_no_credential] + } + } + + fun autofillImplementation( + requestJson: String + ) { + // [START android_identity_autofill_construct_request] + // Retrieves the user's saved password for your app. + val getPasswordOption = GetPasswordOption() + + // Get a passkey from the user's public key credential provider. + val getPublicKeyCredentialOption = GetPublicKeyCredentialOption( + requestJson = requestJson + ) + + val getCredRequest = GetCredentialRequest( + listOf(getPasswordOption, getPublicKeyCredentialOption) + ) + // [END android_identity_autofill_construct_request] + + runBlocking { + // [START android_identity_autofill_get_credential_api] + coroutineScope { + try { + val result = credentialManager.getCredential( + context = activityContext, // Use an activity-based context. + request = getCredRequest + ) + handleSignIn(result) + } catch (e: GetCredentialException) { + handleFailure(e) + } + } + // [END android_identity_autofill_get_credential_api] + } + + val usernameEditText: androidx.appcompat.widget.AppCompatEditText = AppCompatEditText(activityContext) + val passwordEditText: androidx.appcompat.widget.AppCompatEditText = AppCompatEditText(activityContext) + + // [START android_identity_autofill_enable_edit_text] + usernameEditText.pendingGetCredentialRequest = PendingGetCredentialRequest( + getCredRequest + ) { response -> + handleSignIn(response) + } + + passwordEditText.pendingGetCredentialRequest = PendingGetCredentialRequest( + getCredRequest + ) { response -> + handleSignIn(response) + } + // [END android_identity_autofill_enable_edit_text] + } + + // [START android_identity_launch_sign_in_flow_2] + fun handleSignIn(result: GetCredentialResponse) { + // Handle the successfully returned credential. + val credential = result.credential + + when (credential) { + is PublicKeyCredential -> { + val responseJson = credential.authenticationResponseJson + // Share responseJson i.e. a GetCredentialResponse on your server to + // validate and authenticate + } + + is PasswordCredential -> { + val username = credential.id + val password = credential.password + // Use id and password to send to your server to validate + // and authenticate + } + + is CustomCredential -> { + // If you are also using any external sign-in libraries, parse them + // here with the utility functions provided. + if (credential.type == ExampleCustomCredential.TYPE) { + try { + val ExampleCustomCredential = + ExampleCustomCredential.createFrom(credential.data) + // Extract the required credentials and complete the authentication as per + // the federated sign in or any external sign in library flow + } catch (e: ExampleCustomCredential.ExampleCustomCredentialParsingException) { + // Unlikely to happen. If it does, you likely need to update the dependency + // version of your external sign-in library. + Log.e(TAG, "Failed to parse an ExampleCustomCredential", e) + } + } else { + // Catch any unrecognized custom credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + else -> { + // Catch any unrecognized credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + } + // [END android_identity_launch_sign_in_flow_2] + + // [START android_identity_create_passkey] + suspend fun createPasskey(requestJson: String, preferImmediatelyAvailableCredentials: Boolean) { + val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( + // Contains the request in JSON format. Uses the standard WebAuthn + // web JSON spec. + requestJson = requestJson, + // Defines whether you prefer to use only immediately available + // credentials, not hybrid credentials, to fulfill this request. + // This value is false by default. + preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials, + ) + + // Execute CreateCredentialRequest asynchronously to register credentials + // for a user account. Handle success and failure cases with the result and + // exceptions, respectively. + coroutineScope { + try { + val result = credentialManager.createCredential( + // Use an activity-based context to avoid undefined system + // UI launching behavior + context = activityContext, + request = createPublicKeyCredentialRequest, + ) + // Handle passkey creation result + } catch (e: CreateCredentialException) { + handleFailure(e) + } + } + } + // [END android_identity_create_passkey] + + // [START android_identity_handle_create_passkey_failure] + fun handleFailure(e: CreateCredentialException) { + when (e) { + is CreatePublicKeyCredentialDomException -> { + // Handle the passkey DOM errors thrown according to the + // WebAuthn spec. + } + is CreateCredentialCancellationException -> { + // The user intentionally canceled the operation and chose not + // to register the credential. + } + is CreateCredentialInterruptedException -> { + // Retry-able error. Consider retrying the call. + } + is CreateCredentialProviderConfigurationException -> { + // Your app is missing the provider configuration dependency. + // Most likely, you're missing the + // "credentials-play-services-auth" module. + } + is CreateCredentialCustomException -> { + // You have encountered an error from a 3rd-party SDK. If you + // make the API call with a request object that's a subclass of + // CreateCustomCredentialRequest using a 3rd-party SDK, then you + // should check for any custom exception type constants within + // that SDK to match with e.type. Otherwise, drop or log the + // exception. + } + else -> Log.w(TAG, "Unexpected exception type ${e::class.java.name}") + } + } + // [END android_identity_handle_create_passkey_failure] + + fun handleFailure(e: GetCredentialException) { } + + // [START android_identity_register_password] + suspend fun registerPassword(username: String, password: String) { + // Initialize a CreatePasswordRequest object. + val createPasswordRequest = + CreatePasswordRequest(id = username, password = password) + + // Create credential and handle result. + coroutineScope { + try { + val result = + credentialManager.createCredential( + // Use an activity based context to avoid undefined + // system UI launching behavior. + activityContext, + createPasswordRequest + ) + // Handle register password result + } catch (e: CreateCredentialException) { + handleFailure(e) + } + } + } + // [END android_identity_register_password] +} + +sealed class ExampleCustomCredential { + class ExampleCustomCredentialParsingException : Throwable() + + companion object { + fun createFrom(data: Bundle): PublicKeyCredential { + return PublicKeyCredential("") + } + + const val TYPE: String = "" + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt new file mode 100644 index 000000000..f13052b49 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/PasskeyWebListener.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.app.Activity +import android.net.Uri +import android.util.Log +import android.webkit.WebView +import android.widget.Toast +import androidx.annotation.UiThread +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject + +// Placeholder for TAG log value. +const val TAG = "" + +// This class is mostly copied from https://github.com/android/identity-samples/blob/main/WebView/CredentialManagerWebView/PasskeyWebListener.kt. + +// [START android_identity_create_listener_passkeys] +// The class talking to Javascript should inherit: +class PasskeyWebListener( + private val activity: Activity, + private val coroutineScope: CoroutineScope, + private val credentialManagerHandler: CredentialManagerHandler +) : WebViewCompat.WebMessageListener { + /** havePendingRequest is true if there is an outstanding WebAuthn request. + There is only ever one request outstanding at a time. */ + private var havePendingRequest = false + + /** pendingRequestIsDoomed is true if the WebView has navigated since + starting a request. The FIDO module cannot be canceled, but the response + will never be delivered in this case. */ + private var pendingRequestIsDoomed = false + + /** replyChannel is the port that the page is listening for a response on. + It is valid if havePendingRequest is true. */ + private var replyChannel: ReplyChannel? = null + + /** + * Called by the page during a WebAuthn request. + * + * @param view Creates the WebView. + * @param message The message sent from the client using injected JavaScript. + * @param sourceOrigin The origin of the HTTPS request. Should not be null. + * @param isMainFrame Should be set to true. Embedded frames are not + supported. + * @param replyProxy Passed in by JavaScript. Allows replying when wrapped in + the Channel. + * @return The message response. + */ + @UiThread + override fun onPostMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + val messageData = message.data ?: return + onRequest( + messageData, + sourceOrigin, + isMainFrame, + JavaScriptReplyChannel(replyProxy) + ) + } + + private fun onRequest( + msg: String, + sourceOrigin: Uri, + isMainFrame: Boolean, + reply: ReplyChannel, + ) { + msg?.let { + val jsonObj = JSONObject(msg) + val type = jsonObj.getString(TYPE_KEY) + val message = jsonObj.getString(REQUEST_KEY) + + if (havePendingRequest) { + postErrorMessage(reply, "The request already in progress", type) + return + } + + replyChannel = reply + if (!isMainFrame) { + reportFailure("Requests from subframes are not supported", type) + return + } + val originScheme = sourceOrigin.scheme + if (originScheme == null || originScheme.lowercase() != "https") { + reportFailure("WebAuthn not permitted for current URL", type) + return + } + + // Verify that origin belongs to your website, + // it's because the unknown origin may gain credential info. + // if (isUnknownOrigin(originScheme)) { + // return + // } + + havePendingRequest = true + pendingRequestIsDoomed = false + + // Use a temporary "replyCurrent" variable to send the data back, while + // resetting the main "replyChannel" variable to null so it’s ready for + // the next request. + val replyCurrent = replyChannel + if (replyCurrent == null) { + Log.i(TAG, "The reply channel was null, cannot continue") + return + } + + when (type) { + CREATE_UNIQUE_KEY -> + this.coroutineScope.launch { + handleCreateFlow(credentialManagerHandler, message, replyCurrent) + } + + GET_UNIQUE_KEY -> this.coroutineScope.launch { + handleGetFlow(credentialManagerHandler, message, replyCurrent) + } + + else -> Log.i(TAG, "Incorrect request json") + } + } + } + + private suspend fun handleCreateFlow( + credentialManagerHandler: CredentialManagerHandler, + message: String, + reply: ReplyChannel, + ) { + try { + havePendingRequest = false + pendingRequestIsDoomed = false + val response = credentialManagerHandler.createPasskey(message) + val successArray = ArrayList() + successArray.add("success") + successArray.add(JSONObject(response.registrationResponseJson)) + successArray.add(CREATE_UNIQUE_KEY) + reply.send(JSONArray(successArray).toString()) + replyChannel = null // setting initial replyChannel for the next request + } catch (e: CreateCredentialException) { + reportFailure( + "Error: ${e.errorMessage} w type: ${e.type} w obj: $e", + CREATE_UNIQUE_KEY + ) + } catch (t: Throwable) { + reportFailure("Error: ${t.message}", CREATE_UNIQUE_KEY) + } + } + + companion object { + /** INTERFACE_NAME is the name of the MessagePort that must be injected into pages. */ + const val INTERFACE_NAME = "__webauthn_interface__" + const val TYPE_KEY = "type" + const val REQUEST_KEY = "request" + const val CREATE_UNIQUE_KEY = "create" + const val GET_UNIQUE_KEY = "get" + /** INJECTED_VAL is the minified version of the JavaScript code described at this class + * heading. The non minified form is found at credmanweb/javascript/encode.js.*/ + const val INJECTED_VAL = """ + var __webauthn_interface__,__webauthn_hooks__;!function(e){console.log("In the hook."),__webauthn_interface__.addEventListener("message",function e(n){var r=JSON.parse(n.data),t=r[2];"get"===t?o(r):"create"===t?u(r):console.log("Incorrect response format for reply")});var n=null,r=null,t=null,a=null;function o(e){if(null!==n&&null!==t){if("success"!=e[0]){var r=t;n=null,t=null,r(new DOMException(e[1],"NotAllowedError"));return}var a=i(e[1]),o=n;n=null,t=null,o(a)}}function l(e){var n=e.length%4;return Uint8Array.from(atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(e.length+(0===n?0:4-n),"=")),function(e){return e.charCodeAt(0)}).buffer}function s(e){return btoa(Array.from(new Uint8Array(e),function(e){return String.fromCharCode(e)}).join("")).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+${'$'}/,"")}function u(e){if(null===r||null===a){console.log("Here: "+r+" and reject: "+a);return}if(console.log("Output back: "+e),"success"!=e[0]){var n=a;r=null,a=null,n(new DOMException(e[1],"NotAllowedError"));return}var t=i(e[1]),o=r;r=null,a=null,o(t)}function i(e){return console.log("Here is the response from credential manager: "+e),e.rawId=l(e.rawId),e.response.clientDataJSON=l(e.response.clientDataJSON),e.response.hasOwnProperty("attestationObject")&&(e.response.attestationObject=l(e.response.attestationObject)),e.response.hasOwnProperty("authenticatorData")&&(e.response.authenticatorData=l(e.response.authenticatorData)),e.response.hasOwnProperty("signature")&&(e.response.signature=l(e.response.signature)),e.response.hasOwnProperty("userHandle")&&(e.response.userHandle=l(e.response.userHandle)),e.getClientExtensionResults=function e(){return{}},e}e.create=function n(t){if(!("publicKey"in t))return e.originalCreateFunction(t);var o=new Promise(function(e,n){r=e,a=n}),l=t.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}if(l.hasOwnProperty("user")&&l.user.hasOwnProperty("id")){var i=s(l.user.id);l.user.id=i}var c=JSON.stringify({type:"create",request:l});return __webauthn_interface__.postMessage(c),o},e.get=function r(a){if(!("publicKey"in a))return e.originalGetFunction(a);var o=new Promise(function(e,r){n=e,t=r}),l=a.publicKey;if(l.hasOwnProperty("challenge")){var u=s(l.challenge);l.challenge=u}var i=JSON.stringify({type:"get",request:l});return __webauthn_interface__.postMessage(i),o},e.onReplyGet=o,e.CM_base64url_decode=l,e.CM_base64url_encode=s,e.onReplyCreate=u}(__webauthn_hooks__||(__webauthn_hooks__={})),__webauthn_hooks__.originalGetFunction=navigator.credentials.get,__webauthn_hooks__.originalCreateFunction=navigator.credentials.create,navigator.credentials.get=__webauthn_hooks__.get,navigator.credentials.create=__webauthn_hooks__.create,window.PublicKeyCredential=function(){},window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable=function(){return Promise.resolve(!1)}; + """ + } + // [END android_identity_create_listener_passkeys] + + // Handles the get flow in a less error-prone way + private suspend fun handleGetFlow( + credentialManagerHandler: CredentialManagerHandler, + message: String, + reply: ReplyChannel, + ) { + try { + havePendingRequest = false + pendingRequestIsDoomed = false + val r = credentialManagerHandler.getPasskey(message) + val successArray = ArrayList() + successArray.add("success") + successArray.add( + JSONObject( + (r.credential as PublicKeyCredential).authenticationResponseJson + ) + ) + successArray.add(GET_UNIQUE_KEY) + reply.send(JSONArray(successArray).toString()) + replyChannel = null // setting initial replyChannel for next request given temp 'reply' + } catch (e: GetCredentialException) { + reportFailure("Error: ${e.errorMessage} w type: ${e.type} w obj: $e", GET_UNIQUE_KEY) + } catch (t: Throwable) { + reportFailure("Error: ${t.message}", GET_UNIQUE_KEY) + } + } + + /** Sends an error result to the page. */ + private fun reportFailure(message: String, type: String) { + havePendingRequest = false + pendingRequestIsDoomed = false + val reply: ReplyChannel = replyChannel!! // verifies non null by throwing NPE + replyChannel = null + postErrorMessage(reply, message, type) + } + + private fun postErrorMessage(reply: ReplyChannel, errorMessage: String, type: String) { + Log.i(TAG, "Sending error message back to the page via replyChannel $errorMessage") + val array: MutableList = ArrayList() + array.add("error") + array.add(errorMessage) + array.add(type) + reply.send(JSONArray(array).toString()) + var toastMsg = errorMessage + Toast.makeText(this.activity.applicationContext, toastMsg, Toast.LENGTH_SHORT).show() + } + + // [START android_identity_javascript_reply_channel] + // The setup for the reply channel allows communication with JavaScript. + private class JavaScriptReplyChannel(private val reply: JavaScriptReplyProxy) : + ReplyChannel { + override fun send(message: String?) { + try { + reply.postMessage(message!!) + } catch (t: Throwable) { + Log.i(TAG, "Reply failure due to: " + t.message) + } + } + } + + // ReplyChannel is the interface where replies to the embedded site are + // sent. This allows for testing since AndroidX bans mocking its objects. + interface ReplyChannel { + fun send(message: String?) + } + // [END android_identity_javascript_reply_channel] +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/RestoreCredentialsFunctions.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/RestoreCredentialsFunctions.kt new file mode 100644 index 000000000..8ce9700e7 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/RestoreCredentialsFunctions.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.content.Context +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.ClearCredentialStateRequest.Companion.TYPE_CLEAR_RESTORE_CREDENTIAL +import androidx.credentials.CreateRestoreCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetRestoreCredentialOption + +class RestoreCredentialsFunctions( + private val context: Context, + private val credentialManager: CredentialManager, +) { + suspend fun createRestoreKey( + createRestoreRequest: CreateRestoreCredentialRequest + ) { + // [START android_identity_restore_cred_create] + val credentialManager = CredentialManager.create(context) + + // On a successful authentication create a Restore Key + // Pass in the context and CreateRestoreCredentialRequest object + val response = credentialManager.createCredential(context, createRestoreRequest) + // [END android_identity_restore_cred_create] + } + + suspend fun getRestoreKey( + fetchAuthenticationJson: () -> String, + ) { + // [START android_identity_restore_cred_get] + // Fetch the Authentication JSON from server + val authenticationJson = fetchAuthenticationJson() + + // Create the GetRestoreCredentialRequest object + val options = GetRestoreCredentialOption(authenticationJson) + val getRequest = GetCredentialRequest(listOf(options)) + + // The restore key can be fetched in two scenarios to + // 1. On the first launch of app on the device, fetch the Restore Key + // 2. In the onRestore callback (if the app implements the Backup Agent) + val response = credentialManager.getCredential(context, getRequest) + // [END android_identity_restore_cred_get] + } + + suspend fun deleteRestoreKey() { + // [START android_identity_restore_cred_delete] + // Create a ClearCredentialStateRequest object + val clearRequest = ClearCredentialStateRequest(TYPE_CLEAR_RESTORE_CREDENTIAL) + + // On user log-out, clear the restore key + val response = credentialManager.clearCredentialState(clearRequest) + // [END android_identity_restore_cred_delete] + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SignInWithGoogleFunctions.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SignInWithGoogleFunctions.kt new file mode 100644 index 000000000..28c58ef25 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SignInWithGoogleFunctions.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.content.Context +import android.util.Log +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.GetCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import kotlinx.coroutines.coroutineScope + +const val WEB_CLIENT_ID = "" +class SignInWithGoogleFunctions( + context: Context, +) { + private val credentialManager = CredentialManager.create(context) + private val activityContext = context + // Placeholder for TAG log value. + val TAG = "" + + fun createGoogleIdOption(nonce: String): GetGoogleIdOption { + // [START android_identity_siwg_instantiate_request] + val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(true) + .setServerClientId(WEB_CLIENT_ID) + .setAutoSelectEnabled(true) + // nonce string to use when generating a Google ID token + .setNonce(nonce) + .build() + // [END android_identity_siwg_instantiate_request] + + return googleIdOption + } + + private val googleIdOption = createGoogleIdOption("") + + suspend fun signInUser() { + // [START android_identity_siwg_signin_flow_create_request] + val request: GetCredentialRequest = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + coroutineScope { + try { + val result = credentialManager.getCredential( + request = request, + context = activityContext, + ) + handleSignIn(result) + } catch (e: GetCredentialException) { + // Handle failure + } + } + // [END android_identity_siwg_signin_flow_create_request] + } + + // [START android_identity_siwg_signin_flow_handle_signin] + fun handleSignIn(result: GetCredentialResponse) { + // Handle the successfully returned credential. + val credential = result.credential + val responseJson: String + + when (credential) { + + // Passkey credential + is PublicKeyCredential -> { + // Share responseJson such as a GetCredentialResponse to your server to validate and + // authenticate + responseJson = credential.authenticationResponseJson + } + + // Password credential + is PasswordCredential -> { + // Send ID and password to your server to validate and authenticate. + val username = credential.id + val password = credential.password + } + + // GoogleIdToken credential + is CustomCredential -> { + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + try { + // Use googleIdTokenCredential and extract the ID to validate and + // authenticate on your server. + val googleIdTokenCredential = GoogleIdTokenCredential + .createFrom(credential.data) + // You can use the members of googleIdTokenCredential directly for UX + // purposes, but don't use them to store or control access to user + // data. For that you first need to validate the token: + // pass googleIdTokenCredential.getIdToken() to the backend server. + // see [validation instructions](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token) + } catch (e: GoogleIdTokenParsingException) { + Log.e(TAG, "Received an invalid google id token response", e) + } + } else { + // Catch any unrecognized custom credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + + else -> { + // Catch any unrecognized credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + } + // [END android_identity_siwg_signin_flow_handle_signin] + + fun createGoogleSignInWithGoogleOption(nonce: String): GetSignInWithGoogleOption { + // [START android_identity_siwg_get_siwg_option] + val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption.Builder( + serverClientId = WEB_CLIENT_ID + ).setNonce(nonce) + .build() + // [END android_identity_siwg_get_siwg_option] + + return signInWithGoogleOption + } + + // [START android_identity_handle_siwg_option] + fun handleSignInWithGoogleOption(result: GetCredentialResponse) { + // Handle the successfully returned credential. + val credential = result.credential + + when (credential) { + is CustomCredential -> { + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + try { + // Use googleIdTokenCredential and extract id to validate and + // authenticate on your server. + val googleIdTokenCredential = GoogleIdTokenCredential + .createFrom(credential.data) + } catch (e: GoogleIdTokenParsingException) { + Log.e(TAG, "Received an invalid google id token response", e) + } + } else { + // Catch any unrecognized credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + + else -> { + // Catch any unrecognized credential type here. + Log.e(TAG, "Unexpected type of credential") + } + } + } + // [END android_identity_handle_siwg_option] + + fun googleIdOptionFalseFilter() { + // [START android_identity_siwg_instantiate_request_2] + val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(WEB_CLIENT_ID) + .build() + // [END android_identity_siwg_instantiate_request_2] + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SingleTap.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SingleTap.kt new file mode 100644 index 000000000..b61ddbe5c --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SingleTap.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.os.Build.VERSION_CODES +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.BiometricPromptData +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.PendingIntentHandler + +class SingleTap : ComponentActivity() { + private val x: Any? = null + private val TAG: String = "" + + private fun passkeyCreation( + request: BeginCreatePublicKeyCredentialRequest, + passwordCount: Int, + passkeyCount: Int + ) { + val option = null + val origin = null + val responseBuilder = null + val autoSelectEnabled = null + val allowedAuthenticator = 0 + + val y = + // [START android_identity_single_tap_set_biometric_prompt_data] + PublicKeyCredentialEntry( + // other properties... + + biometricPromptData = BiometricPromptData( + allowedAuthenticators = allowedAuthenticator + ) + ) + // [END android_identity_single_tap_set_biometric_prompt_data] + + when (x) { + // [START android_identity_single_tap_pk_creation] + is BeginCreatePublicKeyCredentialRequest -> { + Log.i(TAG, "Request is passkey type") + return handleCreatePasskeyQuery(request, passwordCount, passkeyCount) + } + // [END android_identity_single_tap_pk_creation] + + // [START android_identity_single_tap_pk_flow] + is BeginGetPublicKeyCredentialOption -> { + // ... other logic + + populatePasskeyData( + origin, + option, + responseBuilder, + autoSelectEnabled, + allowedAuthenticator + ) + + // ... other logic as needed + } + // [END android_identity_single_tap_pk_flow] + } + } + + private fun handleCreatePasskeyQuery( + request: BeginCreatePublicKeyCredentialRequest, + passwordCount: Int, + passkeyCount: Int + ) { + val allowedAuthenticator = 0 + + // [START android_identity_single_tap_create_entry] + val createEntry = CreateEntry( + // Additional properties... + biometricPromptData = BiometricPromptData( + allowedAuthenticators = allowedAuthenticator + ), + ) + // [END android_identity_single_tap_create_entry] + } + + @RequiresApi(VERSION_CODES.M) + private fun handleCredentialEntrySelection( + accountId: String = "", + createPasskey: (String, CallingAppInfo, ByteArray?, String) -> Unit + ) { + // [START android_identity_single_tap_handle_credential_entry] + val createRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + if (createRequest == null) { + Log.i(TAG, "request is null") + setUpFailureResponseAndFinish("Unable to extract request from intent") + return + } + // Other logic... + + val biometricPromptResult = createRequest.biometricPromptResult + + // Add your logic based on what needs to be done + // after getting biometrics + + if (createRequest.callingRequest is CreatePublicKeyCredentialRequest) { + val publicKeyRequest: CreatePublicKeyCredentialRequest = + createRequest.callingRequest as CreatePublicKeyCredentialRequest + + if (biometricPromptResult == null) { + // Do your own authentication flow, if needed + } else if (biometricPromptResult.isSuccessful) { + createPasskey( + publicKeyRequest.requestJson, + createRequest.callingAppInfo, + publicKeyRequest.clientDataHash, + accountId + ) + } else { + val error = biometricPromptResult.authenticationError + // Process the error + } + + // Other logic... + } + // [END android_identity_single_tap_handle_credential_entry] + } + + @RequiresApi(VERSION_CODES.M) + private fun retrieveProviderGetCredentialRequest( + validatePasskey: (String, String, String, String, String, String, String) -> Unit, + publicKeyRequest: CreatePublicKeyCredentialRequest, + origin: String, + uid: String, + passkey: PK, + credId: String, + privateKey: String, + ) { + // [START android_identity_single_tap_get_cred_request] + val getRequest = + PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + + if (getRequest == null) { + Log.i(TAG, "request is null") + setUpFailureResponseAndFinish("Unable to extract request from intent") + return + } + + // Other logic... + + val biometricPromptResult = getRequest.biometricPromptResult + + // Add your logic based on what needs to be done + // after getting biometrics + + if (biometricPromptResult == null) { + // Do your own authentication flow, if necessary + } else if (biometricPromptResult.isSuccessful) { + + Log.i(TAG, "The response from the biometricPromptResult was ${biometricPromptResult.authenticationResult?.authenticationType}") + + validatePasskey( + publicKeyRequest.requestJson, + origin, + packageName, + uid, + passkey.username, + credId, + privateKey + ) + } else { + val error = biometricPromptResult.authenticationError + // Process the error + } + + // Other logic... + // [END android_identity_single_tap_get_cred_request] + } + + private fun CreateEntry(biometricPromptData: BiometricPromptData) {} + + private fun PublicKeyCredentialEntry(biometricPromptData: BiometricPromptData) {} + + private fun populatePasskeyData( + origin: Any?, + option: Any?, + responseBuilder: Any?, + autoSelectEnabled: Any?, + allowedAuthenticator: Any? + ) {} + + private fun setUpFailureResponseAndFinish(str: String) {} +} + +data class PK( + val username: String, +) diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SmartLockToCredMan.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SmartLockToCredMan.kt new file mode 100644 index 000000000..f37f6d9da --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/SmartLockToCredMan.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.content.Context +import android.util.Log +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCustomException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnsupportedException +import androidx.credentials.exceptions.NoCredentialException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class SmartLockToCredMan( + private val credentialManager: CredentialManager, + private val activityContext: Context, + private val coroutineScope: CoroutineScope, +) { + // [START android_identity_init_password_option] + // Retrieves the user's saved password for your app from their + // password provider. + val getPasswordOption = GetPasswordOption() + // [END android_identity_init_password_option] + + // [START android_identity_get_cred_request] + val getCredRequest = GetCredentialRequest( + listOf(getPasswordOption) + ) + // [END android_identity_get_cred_request] + + val TAG: String = "tag" + + // [START android_identity_launch_sign_in_flow] + fun launchSignInFlow() { + coroutineScope.launch { + try { + // Attempt to retrieve the credential from the Credential Manager. + val result = credentialManager.getCredential( + // Use an activity-based context to avoid undefined system UI + // launching behavior. + context = activityContext, + request = getCredRequest + ) + + // Process the successfully retrieved credential. + handleSignIn(result) + } catch (e: GetCredentialException) { + // Handle any errors that occur during the credential retrieval + // process. + handleFailure(e) + } + } + } + + private fun handleSignIn(result: GetCredentialResponse) { + // Extract the credential from the response. + val credential = result.credential + + // Determine the type of credential and handle it accordingly. + when (credential) { + is PasswordCredential -> { + val username = credential.id + val password = credential.password + + // Use the extracted username and password to perform + // authentication. + } + + else -> { + // Handle unrecognized credential types. + Log.e(TAG, "Unexpected type of credential") + } + } + } + + private fun handleFailure(e: GetCredentialException) { + // Handle specific credential retrieval errors. + when (e) { + is GetCredentialCancellationException -> { + /* This exception is thrown when the user intentionally cancels + the credential retrieval operation. Update the application's state + accordingly. */ + } + + is GetCredentialCustomException -> { + /* This exception is thrown when a custom error occurs during the + credential retrieval flow. Refer to the documentation of the + third-party SDK used to create the GetCredentialRequest for + handling this exception. */ + } + + is GetCredentialInterruptedException -> { + /* This exception is thrown when an interruption occurs during the + credential retrieval flow. Determine whether to retry the + operation or proceed with an alternative authentication method. */ + } + + is GetCredentialProviderConfigurationException -> { + /* This exception is thrown when there is a mismatch in + configurations for the credential provider. Verify that the + provider dependency is included in the manifest and that the + required system services are enabled. */ + } + + is GetCredentialUnknownException -> { + /* This exception is thrown when the credential retrieval + operation fails without providing any additional details. Handle + the error appropriately based on the application's context. */ + } + + is GetCredentialUnsupportedException -> { + /* This exception is thrown when the device does not support the + Credential Manager feature. Inform the user that credential-based + authentication is unavailable and guide them to an alternative + authentication method. */ + } + + is NoCredentialException -> { + /* This exception is thrown when there are no viable credentials + available for the user. Prompt the user to sign up for an account + or provide an alternative authentication method. Upon successful + authentication, store the login information using + androidx.credentials.CredentialManager.createCredential to + facilitate easier sign-in the next time. */ + } + + else -> { + // Handle unexpected exceptions. + Log.w(TAG, "Unexpected exception type: ${e::class.java.name}") + } + } + } + // [END android_identity_launch_sign_in_flow] +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt new file mode 100644 index 000000000..935da4971 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/WebViewMainActivity.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager + +import android.graphics.Bitmap +import android.os.Bundle +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import kotlinx.coroutines.CoroutineScope + +class WebViewMainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // [START android_identity_initialize_the_webview] + val credentialManagerHandler = CredentialManagerHandler(this) + + setContent { + val coroutineScope = rememberCoroutineScope() + AndroidView( + factory = { + WebView(it).apply { + settings.javaScriptEnabled = true + + // Test URL: + val url = "https://passkeys-codelab.glitch.me/" + val listenerSupported = WebViewFeature.isFeatureSupported( + WebViewFeature.WEB_MESSAGE_LISTENER + ) + if (listenerSupported) { + // Inject local JavaScript that calls Credential Manager. + hookWebAuthnWithListener( + this, this@WebViewMainActivity, + coroutineScope, credentialManagerHandler + ) + } else { + // Fallback routine for unsupported API levels. + } + loadUrl(url) + } + } + ) + } + // [END android_identity_initialize_the_webview] + } + + /** + * Connects the local app logic with the web page via injection of javascript through a + * WebListener. Handles ensuring the [PasskeyWebListener] is hooked up to the webView page + * if compatible. + */ + fun hookWebAuthnWithListener( + webView: WebView, + activity: WebViewMainActivity, + coroutineScope: CoroutineScope, + credentialManagerHandler: CredentialManagerHandler + ) { + // [START android_identity_create_webview_object] + val passkeyWebListener = PasskeyWebListener(activity, coroutineScope, credentialManagerHandler) + + val webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + webView.evaluateJavascript(PasskeyWebListener.INJECTED_VAL, null) + } + } + + webView.webViewClient = webViewClient + // [END android_identity_create_webview_object] + + // [START android_identity_set_web] + val rules = setOf("*") + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, PasskeyWebListener.INTERFACE_NAME, + rules, passkeyWebListener + ) + } + // [END android_identity_set_web] + } +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Color.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Color.kt new file mode 100644 index 000000000..027a2e3fd --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Color.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Theme.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Theme.kt new file mode 100644 index 000000000..aa8223784 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Theme.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun SnippetsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Type.kt b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Type.kt new file mode 100644 index 000000000..5e0eaf6b2 --- /dev/null +++ b/identity/credentialmanager/src/main/java/com/example/identity/credentialmanager/ui/theme/Type.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 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 + * + * https://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.example.identity.credentialmanager.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/identity/credentialmanager/src/main/jsonSnippets.json b/identity/credentialmanager/src/main/jsonSnippets.json new file mode 100644 index 000000000..9cbccd4e1 --- /dev/null +++ b/identity/credentialmanager/src/main/jsonSnippets.json @@ -0,0 +1,135 @@ +/* + * Copyright 2025 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 + * + * https://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. + */ + +{ + "snippets": [ + { + "DigitalAssetLinking": + // Digital asset linking + // [START android_identity_assetlinks_json] + [ + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.android", + "sha256_cert_fingerprints": [ + SHA_HEX_VALUE + ] + } + } + ] + // [END android_identity_assetlinks_json] + }, + + { + "FormatJsonRequestPasskey": + // JSON request format + // [START android_identity_format_json_request_passkey] + { + "challenge": "T1xCsnxM2DNL2KdK5CLa6fMhD7OBqho6syzInk_n-Uo", + "allowCredentials": [], + "timeout": 1800000, + "userVerification": "required", + "rpId": "https://passkeys-codelab.glitch.me/" + } + // [END android_identity_format_json_request_passkey] + }, + + { + "FormatJsonResponsePasskey": + // JSON response format + // [START android_identity_format_json_response_passkey] + { + "id": "KEDetxZcUfinhVi6Za5nZQ", + "type": "public-key", + "rawId": "KEDetxZcUfinhVi6Za5nZQ", + "response": { + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVDF4Q3NueE0yRE5MMktkSzVDTGE2Zk1oRDdPQnFobzZzeXpJbmtfbi1VbyIsIm9yaWdpbiI6ImFuZHJvaWQ6YXBrLWtleS1oYXNoOk1MTHpEdll4UTRFS1R3QzZVNlpWVnJGUXRIOEdjVi0xZDQ0NEZLOUh2YUkiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20uZ29vZ2xlLmNyZWRlbnRpYWxtYW5hZ2VyLnNhbXBsZSJ9", + "authenticatorData": "j5r_fLFhV-qdmGEwiukwD5E_5ama9g0hzXgN8thcFGQdAAAAAA", + "signature": "MEUCIQCO1Cm4SA2xiG5FdKDHCJorueiS04wCsqHhiRDbbgITYAIgMKMFirgC2SSFmxrh7z9PzUqr0bK1HZ6Zn8vZVhETnyQ", + "userHandle": "2HzoHm_hY0CjuEESY9tY6-3SdjmNHOoNqaPDcZGzsr0" + } + } + // [END android_identity_format_json_response_passkey] + }, + { + "CreatePasskeyJsonRequest": + // Json request for creating a passkey + // [START android_identity_create_passkey_request_json] + { + "challenge": "abc123", + "rp": { + "name": "Credential Manager example", + "id": "credential-manager-test.example.com" + }, + "user": { + "id": "def456", + "name": "helloandroid@gmail.com", + "displayName": "helloandroid@gmail.com" + }, + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + }, + { + "type": "public-key", + "alg": -257 + } + ], + "timeout": 1800000, + "attestation": "none", + "excludeCredentials": [ + { + "id": "ghi789", + "type": "public-key" + }, + { + "id": "jkl012", + "type": "public-key" + } + ], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "requireResidentKey": true, + "residentKey": "required", + "userVerification": "required" + } + } + // [END android_identity_create_passkey_request_json] + }, + { + "CreatePasskeyHandleJsonResponse": + // Json response when creating a passkey + // [START android_identity_create_passkey_response_json] + { + "id": "KEDetxZcUfinhVi6Za5nZQ", + "type": "public-key", + "rawId": "KEDetxZcUfinhVi6Za5nZQ", + "response": { + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibmhrUVhmRTU5SmI5N1Z5eU5Ka3ZEaVh1Y01Fdmx0ZHV2Y3JEbUdyT0RIWSIsIm9yaWdpbiI6ImFuZHJvaWQ6YXBrLWtleS1oYXNoOk1MTHpEdll4UTRFS1R3QzZVNlpWVnJGUXRIOEdjVi0xZDQ0NEZLOUh2YUkiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20uZ29vZ2xlLmNyZWRlbnRpYWxtYW5hZ2VyLnNhbXBsZSJ9", + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUj5r_fLFhV-qdmGEwiukwD5E_5ama9g0hzXgN8thcFGRdAAAAAAAAAAAAAAAAAAAAAAAAAAAAEChA3rcWXFH4p4VYumWuZ2WlAQIDJiABIVgg4RqZaJyaC24Pf4tT-8ONIZ5_Elddf3dNotGOx81jj3siWCAWXS6Lz70hvC2g8hwoLllOwlsbYatNkO2uYFO-eJID6A" + } + } + // [END android_identity_create_passkey_response_json] + } + + ] +} \ No newline at end of file diff --git a/identity/credentialmanager/src/main/othersnippets b/identity/credentialmanager/src/main/othersnippets new file mode 100644 index 000000000..3a82843c6 --- /dev/null +++ b/identity/credentialmanager/src/main/othersnippets @@ -0,0 +1,18 @@ +// [START android_identity_apk_key_hash] +android:apk-key-hash: +// [END android_identity_apk_key_hash] + +// [START android_identity_keytool_sign] +keytool -list -keystore +// [END android_identity_keytool_sign] + +// [START android_identity_fingerprint_decode_python] +import binascii +import base64 +fingerprint = '91:F7:CB:F9:D6:81:53:1B:C7:A5:8F:B8:33:CC:A1:4D:AB:ED:E5:09:C5' +print("android:apk-key-hash:" + base64.urlsafe_b64encode(binascii.a2b_hex(fingerprint.replace(':', ''))).decode('utf8').replace('=', '')) +// [END android_identity_fingerprint_decode_python] + +// [START android_identity_fingerprint_decoded] +android:apk-key-hash:kffL-daBUxvHpY-4M8yhTavt5QnFEI2LsexohxrGPYU +// [END android_identity_fingerprint_decoded] \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/drawable-v24/ic_launcher_foreground.xml b/identity/credentialmanager/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/identity/credentialmanager/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/drawable/ic_launcher_background.xml b/identity/credentialmanager/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/identity/credentialmanager/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/identity/credentialmanager/src/main/res/layout-v34/xmlsnippets.xml b/identity/credentialmanager/src/main/res/layout-v34/xmlsnippets.xml new file mode 100644 index 000000000..9be5cf21f --- /dev/null +++ b/identity/credentialmanager/src/main/res/layout-v34/xmlsnippets.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/identity/credentialmanager/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher.webp b/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher.webp b/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher.webp b/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/identity/credentialmanager/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/identity/credentialmanager/src/main/res/values/colors.xml b/identity/credentialmanager/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/identity/credentialmanager/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/values/strings.xml b/identity/credentialmanager/src/main/res/values/strings.xml new file mode 100644 index 000000000..8f5fb8e80 --- /dev/null +++ b/identity/credentialmanager/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + credentialmanager + // [START android_identity_assetlinks_app_association] + + [{ + \"include\": \"https://signin.example.com/.well-known/assetlinks.json/" + }] + + // [END android_identity_assetlinks_app_association] + \ No newline at end of file diff --git a/identity/credentialmanager/src/main/res/values/themes.xml b/identity/credentialmanager/src/main/res/values/themes.xml new file mode 100644 index 000000000..65078ebe0 --- /dev/null +++ b/identity/credentialmanager/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +