diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..39bbd2681 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} diff --git a/.github/workflows/build-snapshot.yml b/.github/workflows/build-snapshot.yml index 9f09e694d..4c8f16d2f 100644 --- a/.github/workflows/build-snapshot.yml +++ b/.github/workflows/build-snapshot.yml @@ -34,7 +34,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt @@ -105,7 +107,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt @@ -180,7 +184,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Generate cache key run: ./checksum.sh checksum.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcbba65e5..56967c264 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 @@ -109,7 +111,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 @@ -187,7 +191,9 @@ jobs: java-version: 17 - name: Decrypt secrets - run: release/signing-setup.sh ${{ secrets.ENCRYPT_KEY }} + run: release/signing-setup.sh + env: + ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }} - name: Setup Gradle uses: gradle/gradle-build-action@v2 diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 000000000..d6c47a7f7 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,11 @@ +## 2024-05-22 - [Permission Samples UX] +**Learning:** Enhancing permission rationale screens with illustrative icons and specific action-oriented button labels (e.g., "Allow camera access" vs. "Request permission") improves user trust and clarity. Using `Arrangement.spacedBy` in `Column` ensures consistent visual rhythm without manual spacers. +**Action:** Use `Icons.Default.Face` for rationale prompts and `Icons.Default.Done` with success colors for granted states in Compose-based permission flows. + +## 2024-06-12 - [Hierarchical Sample Navigation] +**Learning:** In sample apps with nested categories, static TopAppBar titles and missing back buttons disorient users. Contextual titles (last segment of path) and a standard back icon improve orientation and navigation flow. +**Action:** Use `Icons.AutoMirrored.Filled.ArrowBack` for back buttons and always add `Role.Button` to clickable list items for accessibility. + +## 2024-12-19 - [Navigation Feedback in Samples] +**Learning:** Interactive navigation components (NavRail, NavDrawer) in sample apps feel "broken" if they don't provide immediate feedback in the main content area. Lifting selection state and displaying the selected item's identity (icon/label) validates user interaction. +**Action:** Always provide a visual response in the main content area when a navigation item is selected, and set `contentDescription = null` for icons accompanied by labels to avoid redundant screen reader announcements. diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 000000000..f6b4e8f7e --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,14 @@ +## 2025-05-14 - [Activity Injection in Sample App Launcher] +**Vulnerability:** The sample application's `MainActivity` used `queryIntentActivities` to dynamically populate its list of samples based on an intent category (`com.google.accompanist.sample.SAMPLE_CODE`) without verifying the package name of the returned activities. +**Learning:** Malicious external applications could register activities with the same category and inject themselves into the sample app's UI, potentially leading to phishing or UI redressing attacks. +**Prevention:** Always verify that `info.activityInfo.packageName` matches the application's own `packageName` when dynamically loading components from `PackageManager` based on shared intent categories. + +## 2024-05-15 - [Insecure Hashing and Shell Script Weakness] +**Vulnerability:** The `checksum.sh` script used MD5 for integrity checks and lacked variable quoting, making it susceptible to collisions and word-splitting vulnerabilities. +**Learning:** Legacy scripts often use older hashing algorithms like MD5 which are cryptographically broken. Additionally, unquoted variables in shell scripts can lead to unintended execution or script failure if paths contain special characters. +**Prevention:** Use SHA-256 for all integrity and security-sensitive hashing. Always wrap shell variables in double quotes (e.g., `"$VAR"`) to prevent word-splitting and globbing attacks. + +## 2024-05-16 - [Secret Exposure in Process List via openssl -k] +**Vulnerability:** The `release/signing-setup.sh` script used the legacy `openssl -k` flag to pass an encryption key as a command-line argument, making it visible to all users on the system via process monitoring tools. +**Learning:** Command-line arguments are inherently public on most systems. Using them for secrets is a major security risk, especially in shared CI/CD environments. +**Prevention:** Always use environment variables, files, or descriptor-based methods to pass secrets to CLI tools. In `openssl`, use `-pass env:VARIABLE_NAME` to read the secret from an environment variable securely. diff --git a/checksum.sh b/checksum.sh index 7d480521c..a5a06fc8f 100755 --- a/checksum.sh +++ b/checksum.sh @@ -14,25 +14,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -RESULT_FILE=$1 +set -e -if [ -f $RESULT_FILE ]; then - rm $RESULT_FILE +RESULT_FILE="$1" + +if [ -f "$RESULT_FILE" ]; then + rm "$RESULT_FILE" fi -touch $RESULT_FILE +touch "$RESULT_FILE" checksum_file() { - echo $(openssl md5 $1 | awk '{print $2}') + # Use SHA-256 for better security and collision resistance compared to MD5. + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + openssl dgst -sha256 -r "$1" | awk '{print $1}' + fi } FILES=() -while read -r -d ''; do - FILES+=("$REPLY") +while read -r -d '' FILE; do + FILES+=("$FILE") done < <(find . -type f \( -name "build.gradle*" -o -name "*.versions.toml" -o -name "gradle-wrapper.properties" \) -print0) -# Loop through files and append MD5 to result file -for FILE in ${FILES[@]}; do - echo $(checksum_file $FILE) >> $RESULT_FILE +# Loop through files and append SHA-256 to result file +for FILE in "${FILES[@]}"; do + echo "$(checksum_file "$FILE")" >> "$RESULT_FILE" done # Now sort the file so that it is idempotent -sort $RESULT_FILE -o $RESULT_FILE +sort "$RESULT_FILE" -o "$RESULT_FILE" diff --git a/gradle.properties b/gradle.properties index 7ef419307..7d9c13321 100644 --- a/gradle.properties +++ b/gradle.properties @@ -39,11 +39,11 @@ POM_DESCRIPTION=Utilities for Jetpack Compose POM_URL=https://github.com/google/accompanist/ POM_SCM_URL=https://github.com/google/accompanist/ -POM_SCM_CONNECTION=scm:git:git://github.com/google/accompanist.git -POM_SCM_DEV_CONNECTION=scm:git:git://github.com/google/accompanist.git +POM_SCM_CONNECTION=scm:git:https://github.com/google/accompanist.git +POM_SCM_DEV_CONNECTION=scm:git:https://github.com/google/accompanist.git POM_LICENCE_NAME=The Apache Software License, Version 2.0 -POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt POM_LICENCE_DIST=repo POM_DEVELOPER_ID=google diff --git a/release/signing-setup.sh b/release/signing-setup.sh index de0d70ecd..14d97aa6b 100755 --- a/release/signing-setup.sh +++ b/release/signing-setup.sh @@ -14,15 +14,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -ENCRYPT_KEY=$1 +set -e -if [[ ! -z "$ENCRYPT_KEY" ]]; then +ENCRYPT_KEY="${ENCRYPT_KEY:-$1}" + +if [[ -n "$ENCRYPT_KEY" ]]; then # Decrypt GnuPG keyring - openssl aes-256-cbc -md sha256 -d -in release/secring.gpg.aes -out release/secring.gpg -k ${ENCRYPT_KEY} + openssl aes-256-cbc -md sha256 -d -in release/secring.gpg.aes -out release/secring.gpg -pass env:ENCRYPT_KEY # Decrypt Play Store key - openssl aes-256-cbc -md sha256 -d -in release/signing.properties.aes -out release/signing.properties -k ${ENCRYPT_KEY} + openssl aes-256-cbc -md sha256 -d -in release/signing.properties.aes -out release/signing.properties -pass env:ENCRYPT_KEY else - echo "ENCRYPT_KEY is empty" + echo "ENCRYPT_KEY is empty, skipping decryption" + exit 0 fi diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 8b670aab3..384a67db6 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -34,7 +34,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.NoActionBar"> + android:theme="@android:style/Theme.Material.NoActionBar" + android:usesCleartextTraffic="false"> @@ -51,10 +52,14 @@ + + android:exported="false"> @@ -64,7 +69,7 @@ + android:exported="false"> @@ -74,7 +79,7 @@ + android:exported="false"> @@ -84,7 +89,7 @@ + android:exported="false"> @@ -94,7 +99,7 @@ + android:exported="false"> @@ -104,7 +109,7 @@ + android:exported="false"> @@ -114,7 +119,7 @@ + android:exported="false"> @@ -124,7 +129,7 @@ + android:exported="false"> @@ -134,7 +139,7 @@ + android:exported="false"> diff --git a/sample/src/main/java/com/google/accompanist/sample/MainActivity.kt b/sample/src/main/java/com/google/accompanist/sample/MainActivity.kt index 0f8a2e59a..905ae4d72 100644 --- a/sample/src/main/java/com/google/accompanist/sample/MainActivity.kt +++ b/sample/src/main/java/com/google/accompanist/sample/MainActivity.kt @@ -22,6 +22,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.ui.res.stringResource /** * A list which automatically populates the list of sample activities in this app @@ -32,13 +33,20 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - val data = getData(intent.getStringExtra(EXTRA_PATH)) + val path = intent.getStringExtra(EXTRA_PATH) + val data = getData(path) setContent { AccompanistSampleTheme { MainScreen( listData = data, - onItemClick = { startActivity(it) } + onItemClick = { startActivity(it) }, + title = path?.substringAfterLast('/') ?: stringResource(R.string.app_name), + onBackClick = if (path != null) { + { finish() } + } else { + null + } ) } } @@ -66,6 +74,8 @@ class MainActivity : ComponentActivity() { val entries = mutableMapOf() list.forEach { info -> + if (info.activityInfo.packageName != packageName) return@forEach + val labelSeq = info.loadLabel(packageManager) val label = labelSeq?.toString() ?: info.activityInfo.name @@ -117,4 +127,8 @@ class MainActivity : ComponentActivity() { } } -private const val EXTRA_PATH = "com.example.android.apis.Path" +/* + * Use a project-specific prefix for intent extras to avoid collisions + * and follow security best practices. + */ +private const val EXTRA_PATH = "com.google.accompanist.sample.PATH" diff --git a/sample/src/main/java/com/google/accompanist/sample/MainScreen.kt b/sample/src/main/java/com/google/accompanist/sample/MainScreen.kt index 7ec1e17b9..259239ac9 100644 --- a/sample/src/main/java/com/google/accompanist/sample/MainScreen.kt +++ b/sample/src/main/java/com/google/accompanist/sample/MainScreen.kt @@ -30,7 +30,12 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -41,6 +46,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow data class AccompanistSample( val title: String, @@ -52,13 +59,25 @@ data class AccompanistSample( fun MainScreen( listData: List, onItemClick: (Intent) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + title: String = stringResource(R.string.app_name), + onBackClick: (() -> Unit)? = null ) { Surface(modifier) { Column { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, + title = { Text(title) }, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.navigation_back) + ) + } + } + }, scrollBehavior = scrollBehavior ) @@ -88,8 +107,23 @@ private fun ContentList( ) { items(listData) { ListItem( - headlineText = { Text(it.title) }, - modifier = Modifier.clickable { onItemClick(it.intent) } + headlineText = { + Text( + text = it.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null + ) + }, + modifier = Modifier.clickable( + role = Role.Button, + onClick = { onItemClick(it.intent) } + ) ) } diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt index 9be1475f0..cd1e89927 100644 --- a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt @@ -21,9 +21,12 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done @@ -46,7 +49,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.unit.dp import com.google.accompanist.adaptive.FoldAwareColumn import com.google.accompanist.adaptive.calculateDisplayFeatures @@ -57,7 +62,25 @@ class NavDrawerFoldAwareColumnSample : ComponentActivity() { super.onCreate(savedInstanceState) setContent { AccompanistSample(contentPadding = PaddingValues(all = 0.dp)) { - NavDrawerExample(this) + val icons = remember { + listOf( + Icons.Default.Done, + Icons.Default.Face, + Icons.Default.Lock, + Icons.Default.Search, + Icons.Default.ThumbUp, + Icons.Default.Warning, + Icons.Default.Star + ) + } + var selectedIcon by remember { mutableStateOf(icons[0]) } + + NavDrawerExample( + activity = this@NavDrawerFoldAwareColumnSample, + icons = icons, + selectedIcon = selectedIcon, + onIconSelected = { selectedIcon = it } + ) } } } @@ -65,19 +88,12 @@ class NavDrawerFoldAwareColumnSample : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NavDrawerExample(activity: Activity) { - val icons = listOf( - Icons.Default.Done, - Icons.Default.Face, - Icons.Default.Lock, - Icons.Default.Search, - Icons.Default.ThumbUp, - Icons.Default.Warning, - Icons.Default.Star - ) - - var selectedIcon by remember { mutableStateOf(icons[0]) } - +fun NavDrawerExample( + activity: Activity, + icons: List, + selectedIcon: ImageVector, + onIconSelected: (ImageVector) -> Unit +) { ModalNavigationDrawer( drawerContent = { ModalDrawerSheet { @@ -90,15 +106,32 @@ fun NavDrawerExample(activity: Activity) { modifier = Modifier .padding(5.dp) .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), - icon = { Icon(imageVector = it, contentDescription = it.name) }, + icon = { Icon(imageVector = it, contentDescription = null) }, label = { Text(it.name.substringAfter('.')) }, selected = it == selectedIcon, - onClick = { selectedIcon = it } + onClick = { onIconSelected(it) } ) } } } }, - content = { Surface(modifier = Modifier.fillMaxSize()) {} } + content = { + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = selectedIcon, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = selectedIcon.name.substringAfter('.'), + style = MaterialTheme.typography.headlineSmall + ) + } + } + } + } ) } diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt index 5eb0a4836..21e6cc393 100644 --- a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt @@ -21,9 +21,12 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Face @@ -43,7 +46,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.unit.dp import com.google.accompanist.adaptive.FoldAwareColumn import com.google.accompanist.adaptive.calculateDisplayFeatures @@ -54,9 +59,42 @@ class NavRailFoldAwareColumnSample : ComponentActivity() { super.onCreate(savedInstanceState) setContent { AccompanistSample { + val icons = remember { + listOf( + Icons.Default.Done, + Icons.Default.Face, + Icons.Default.Lock, + Icons.Default.Search, + Icons.Default.ThumbUp, + Icons.Default.Warning, + Icons.Default.Star + ) + } + var selectedIcon by remember { mutableStateOf(icons[0]) } + Row { - NavRail(this@NavRailFoldAwareColumnSample) - Surface(modifier = Modifier.fillMaxSize()) {} + NavRail( + activity = this@NavRailFoldAwareColumnSample, + icons = icons, + selectedIcon = selectedIcon, + onIconSelected = { selectedIcon = it } + ) + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = selectedIcon, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = selectedIcon.name.substringAfter('.'), + style = MaterialTheme.typography.headlineSmall + ) + } + } + } } } } @@ -64,19 +102,12 @@ class NavRailFoldAwareColumnSample : ComponentActivity() { } @Composable -fun NavRail(activity: Activity) { - val icons = listOf( - Icons.Default.Done, - Icons.Default.Face, - Icons.Default.Lock, - Icons.Default.Search, - Icons.Default.ThumbUp, - Icons.Default.Warning, - Icons.Default.Star - ) - - var selectedIcon by remember { mutableStateOf(icons[0]) } - +fun NavRail( + activity: Activity, + icons: List, + selectedIcon: ImageVector, + onIconSelected: (ImageVector) -> Unit +) { NavigationRail { FoldAwareColumn(displayFeatures = calculateDisplayFeatures(activity)) { icons.forEach { @@ -85,8 +116,8 @@ fun NavRail(activity: Activity) { .padding(5.dp) .border(2.dp, MaterialTheme.colorScheme.primary), selected = it == selectedIcon, - onClick = { selectedIcon = it }, - icon = { Icon(imageVector = it, contentDescription = it.name) }, + onClick = { onIconSelected(it) }, + icon = { Icon(imageVector = it, contentDescription = null) }, label = { Text(it.name.substringAfter('.')) } ) } diff --git a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestLocationPermissionsSample.kt b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestLocationPermissionsSample.kt index 51d136ad0..c8863d89e 100644 --- a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestLocationPermissionsSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestLocationPermissionsSample.kt @@ -19,13 +19,23 @@ package com.google.accompanist.sample.permissions import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Face import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -53,39 +63,68 @@ private fun Sample() { ) ) - if (locationPermissionsState.allPermissionsGranted) { - Text("Thanks! I can access your exact location :D") - } else { - Column { - val allPermissionsRevoked = - locationPermissionsState.permissions.size == - locationPermissionsState.revokedPermissions.size - - val textToShow = if (!allPermissionsRevoked) { - // If not all the permissions are revoked, it's because the user accepted the COARSE - // location permission, but not the FINE one. - "Yay! Thanks for letting me access your approximate location. " + - "But you know what would be great? If you allow me to know where you " + - "exactly are. Thank you!" - } else if (locationPermissionsState.shouldShowRationale) { - // Both location permissions have been denied - "Getting your exact location is important for this app. " + - "Please grant us fine location. Thank you :D" - } else { - // First time the user sees this feature or the user doesn't want to be asked again - "This feature requires location permission" + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (locationPermissionsState.allPermissionsGranted) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Thank you! Precise location access is granted.", + textAlign = TextAlign.Center + ) } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Face, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) - val buttonText = if (!allPermissionsRevoked) { - "Allow precise location" - } else { - "Request permissions" - } + val allPermissionsRevoked = + locationPermissionsState.permissions.size == + locationPermissionsState.revokedPermissions.size + + val textToShow = if (!allPermissionsRevoked) { + // If not all the permissions are revoked, it's because the user accepted the COARSE + // location permission, but not the FINE one. + "Yay! Thanks for letting me access your approximate location. " + + "But you know what would be great? If you allow me to know where you " + + "exactly are. Thank you!" + } else if (locationPermissionsState.shouldShowRationale) { + // Both location permissions have been denied + "Getting your exact location is important for this app. " + + "Please grant us fine location. Thank you :D" + } else { + // First time the user sees this feature or the user doesn't want to be asked again + "This feature requires location permission" + } + + val buttonText = if (!allPermissionsRevoked) { + "Allow precise location" + } else { + "Allow location access" + } - Text(text = textToShow) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { locationPermissionsState.launchMultiplePermissionRequest() }) { - Text(buttonText) + Text( + text = textToShow, + textAlign = TextAlign.Center + ) + Button(onClick = { locationPermissionsState.launchMultiplePermissionRequest() }) { + Text(buttonText) + } } } } diff --git a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestMultiplePermissionsSample.kt b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestMultiplePermissionsSample.kt index 2c40b76bf..37973c0f0 100644 --- a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestMultiplePermissionsSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestMultiplePermissionsSample.kt @@ -20,13 +20,23 @@ import android.Manifest import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Face import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState @@ -56,20 +66,44 @@ class RequestMultiplePermissionsSample : ComponentActivity() { @OptIn(ExperimentalPermissionsApi::class) @Composable private fun Sample(multiplePermissionsState: MultiplePermissionsState) { - if (multiplePermissionsState.allPermissionsGranted) { - // If all permissions are granted, then show screen with the feature enabled - Text("Camera and Read storage permissions Granted! Thank you!") - } else { - Column { - Text( - getTextToShowGivenPermissions( - multiplePermissionsState.revokedPermissions, - multiplePermissionsState.shouldShowRationale + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (multiplePermissionsState.allPermissionsGranted) { + // If all permissions are granted, then show screen with the feature enabled + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) { - Text("Request permissions") + Text("Camera and Audio permissions Granted! Thank you!") + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Face, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + Text( + getTextToShowGivenPermissions( + multiplePermissionsState.revokedPermissions, + multiplePermissionsState.shouldShowRationale + ), + textAlign = TextAlign.Center + ) + Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) { + Text("Allow permissions") + } } } } @@ -88,7 +122,17 @@ private fun getTextToShowGivenPermissions( } for (i in permissions.indices) { - textToShow.append(permissions[i].permission) + val readableName = when (permissions[i].permission) { + Manifest.permission.CAMERA -> "camera" + Manifest.permission.RECORD_AUDIO -> "audio" + else -> { + permissions[i].permission + .substringAfterLast(".") + .replace("_", " ") + .lowercase() + } + } + textToShow.append(readableName) when { revokedPermissionsSize > 1 && i == revokedPermissionsSize - 2 -> { textToShow.append(", and ") diff --git a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestPermissionSample.kt b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestPermissionSample.kt index 6960db44f..b6d8e2a3c 100644 --- a/sample/src/main/java/com/google/accompanist/sample/permissions/RequestPermissionSample.kt +++ b/sample/src/main/java/com/google/accompanist/sample/permissions/RequestPermissionSample.kt @@ -19,13 +19,23 @@ package com.google.accompanist.sample.permissions import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Face import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -49,20 +59,43 @@ class RequestPermissionSample : ComponentActivity() { @Composable private fun Sample() { val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) - if (cameraPermissionState.status.isGranted) { - Text("Camera permission Granted") - } else { - Column { - val textToShow = if (cameraPermissionState.status.shouldShowRationale) { - "The camera is important for this app. Please grant the permission." - } else { - "Camera not available" + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (cameraPermissionState.status.isGranted) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text("Camera permission Granted") } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Face, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) - Text(textToShow) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { - Text("Request permission") + val textToShow = if (cameraPermissionState.status.shouldShowRationale) { + "The camera is important for this app. Please grant the permission." + } else { + "The camera permission is required for this feature." + } + + Text(textToShow, textAlign = TextAlign.Center) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Allow camera access") + } } } } diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 5a9126c54..b19be0369 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Accompanist Sample + Back Insets: Basic Insets: Fragment