diff --git a/README.md b/README.md index cd0824dc..59a9ea4a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Browse the samples inside each topic samples folder: - [User-interface](https://github.com/android/platform-samples/tree/main/samples/user-interface) - More to come... -We regularly add new samples to this repository. You can find a list of all the available samples [here](https://github.com/android/platform-samples/tree/main/samples). +We are constantly adding new samples to this repository. You can find a list of all the available samples [here](https://github.com/android/platform-samples/tree/main/samples). ## How to run diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index 1c87b11a..be4ad9e2 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -63,6 +63,7 @@ import com.example.platform.shared.MinSdkBox import com.example.platform.storage.mediastore.MediaStoreQuerySample import com.example.platform.storage.mediastore.SelectedPhotosAccessSample import com.example.platform.storage.photopicker.PhotoPickerSample +import com.example.platform.storage.storageaccessframework.GetContentSample import com.example.platform.ui.appwidgets.AppWidgets import com.example.platform.ui.constraintlayout.AdvancedArrangementFragment import com.example.platform.ui.constraintlayout.AdvancedChainsFragment @@ -566,6 +567,14 @@ val SAMPLE_DEMOS by lazy { apiSurface = StorageApiSurface, content = { MediaStoreQuerySample() }, ), + ComposableSampleDemo( + id = "storageaccessframework-getcontent", + name = "Storage Access Framework - GET_CONTENT", + description = "Open a document using the Storage Access Framework", + documentation = "https://developer.android.com/training/data-storage/shared/documents-files#open-file", + apiSurface = StorageApiSurface, + content = { GetContentSample() }, + ), ComposableSampleDemo( id = "selected-photos-access", name = "Selected Photos Access", diff --git a/samples/accessibility/build.gradle.kts b/samples/accessibility/build.gradle.kts index aa8a0ad3..45d6b7bf 100644 --- a/samples/accessibility/build.gradle.kts +++ b/samples/accessibility/build.gradle.kts @@ -42,6 +42,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) + implementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.fragment) diff --git a/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/DataAccess.kt b/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/DataAccess.kt index 3fe48afe..2d981b07 100644 --- a/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/DataAccess.kt +++ b/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/DataAccess.kt @@ -47,13 +47,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.core.app.AppOpsManagerCompat -//import androidx.core.content.getSystemService import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -//import com.example.platform.base.PermissionBox import com.example.platform.shared.PermissionBox -//import com.google.android.catalog.framework.annotations.Sample import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource @@ -62,13 +59,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await @SuppressLint("MissingPermission") -//@Sample( -// name = "Data Access", -// description = "Demonstrates how to implement data access auditing for your app to identify " + -// "unexpected data access, even from third-party SDKs and libraries.", -// documentation = "https://developer.android.com/guide/topics/data/audit-access", -//) - @RequiresApi(Build.VERSION_CODES.R) @Composable fun DataAccessSample() { diff --git a/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/ScreenshotDetection.kt b/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/ScreenshotDetection.kt index dbf9f415..101ed641 100644 --- a/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/ScreenshotDetection.kt +++ b/samples/privacy/transparency/src/main/java/com/example/platform/privacy/transparency/ScreenshotDetection.kt @@ -39,10 +39,6 @@ import androidx.fragment.app.Fragment import java.text.DateFormat import java.util.Date -//@Sample( -// name = "Screenshot Detection", -// description = "This sample shows how to detect that the user capture the screen in Android 14 onwards", -//) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) class ScreenshotDetectionSample : Fragment() { diff --git a/samples/storage/build.gradle.kts b/samples/storage/build.gradle.kts index a01b756d..f5ce00eb 100644 --- a/samples/storage/build.gradle.kts +++ b/samples/storage/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) diff --git a/samples/storage/src/main/java/com/example/platform/storage/StorageSampleActivity.kt b/samples/storage/src/main/java/com/example/platform/storage/StorageSampleActivity.kt deleted file mode 100644 index 9d4565e8..00000000 --- a/samples/storage/src/main/java/com/example/platform/storage/StorageSampleActivity.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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.platform.storage - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier -import com.example.platform.shared.theme.CatalogTheme - -class StorageSampleActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - CatalogTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Text("Hello World", modifier = Modifier.padding(innerPadding)) - } - } - } - } -} \ No newline at end of file diff --git a/samples/storage/src/main/java/com/example/platform/storage/fileprovider/FileProviderSample.kt b/samples/storage/src/main/java/com/example/platform/storage/fileprovider/FileProviderSample.kt new file mode 100644 index 00000000..384e62d1 --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/fileprovider/FileProviderSample.kt @@ -0,0 +1,2 @@ +package com.example.platform.storage.fileprovider + diff --git a/samples/storage/src/main/java/com/example/platform/storage/mediastore/MediaStoreQuery.kt b/samples/storage/src/main/java/com/example/platform/storage/mediastore/MediaStoreQuery.kt index 26bdb710..71b98739 100644 --- a/samples/storage/src/main/java/com/example/platform/storage/mediastore/MediaStoreQuery.kt +++ b/samples/storage/src/main/java/com/example/platform/storage/mediastore/MediaStoreQuery.kt @@ -45,17 +45,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -//import com.example.platform.base.PermissionBox import com.example.platform.shared.PermissionBox -//import com.google.android.catalog.framework.annotations.Sample import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -//@Sample( -// name = "MediaStore - Query", -// description = "Query files indexed by MediaStore", -// documentation = "https://developer.android.com/training/data-storage/shared/media#media_store", -//) @SuppressLint("MissingPermission") @Composable fun MediaStoreQuerySample() { diff --git a/samples/storage/src/main/java/com/example/platform/storage/mediastore/SelectedPhotosAccess.kt b/samples/storage/src/main/java/com/example/platform/storage/mediastore/SelectedPhotosAccess.kt index 4fb1d6b5..42ef5c98 100644 --- a/samples/storage/src/main/java/com/example/platform/storage/mediastore/SelectedPhotosAccess.kt +++ b/samples/storage/src/main/java/com/example/platform/storage/mediastore/SelectedPhotosAccess.kt @@ -48,12 +48,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -//@Sample( -// name = "Selected Photos Access", -// description = "Check and request storage permissions", -// documentation = "https://developer.android.com/about/versions/14/changes/partial-photo-video-access", -//) -//@RequiresPermission(anyOf = [READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE]) @Composable fun SelectedPhotosAccessSample() { val context = LocalContext.current diff --git a/samples/storage/src/main/java/com/example/platform/storage/photopicker/PhotoPicker.kt b/samples/storage/src/main/java/com/example/platform/storage/photopicker/PhotoPicker.kt index c32d24b1..598f52c7 100644 --- a/samples/storage/src/main/java/com/example/platform/storage/photopicker/PhotoPicker.kt +++ b/samples/storage/src/main/java/com/example/platform/storage/photopicker/PhotoPicker.kt @@ -59,12 +59,6 @@ import coil.compose.AsyncImage * picker. Check out the AndroidManifest.xml of the storage section to see the declaration * that enables backport support on Android KitKat onwards using Google Play Services */ -@OptIn(ExperimentalMaterial3Api::class) -//@Sample( -// name = "PhotoPicker", -// description = "Select images/videos in a privacy-friendly way using the photo picker", -// documentation = "https://developer.android.com/training/data-storage/shared/photopicker", -//) @Composable fun PhotoPickerSample() { var selectedMedia by remember { mutableStateOf(emptyList()) } diff --git a/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetContentSample.kt b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetContentSample.kt new file mode 100644 index 00000000..4bbba74b --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetContentSample.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.platform.storage.storageaccessframework + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.example.platform.storage.R +import com.example.platform.storage.storageaccessframework.shared.AudioFileCard +import com.example.platform.storage.storageaccessframework.shared.BinaryFileCard +import com.example.platform.storage.storageaccessframework.shared.FileRecord +import com.example.platform.storage.storageaccessframework.shared.FileType +import com.example.platform.storage.storageaccessframework.shared.ImageFileCard +import com.example.platform.storage.storageaccessframework.shared.PdfFileCard +import com.example.platform.storage.storageaccessframework.shared.TextFileCard +import com.example.platform.storage.storageaccessframework.shared.VideoFileCard +import kotlinx.coroutines.launch + +@Composable +fun GetContentSample() { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + var selectedFilter by remember { mutableStateOf(FileType.Any) } + var selectMultiple by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + var selectedFiles by remember { mutableStateOf(emptyList()) } + + val getSingleDocument = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + coroutineScope.launch { + selectedFiles = uri?.let { uri -> + FileRecord.fromUri(uri, context)?.let { listOf(it) } + } ?: emptyList() + } + } + + val getMultipleDocuments = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + coroutineScope.launch { + selectedFiles = uris.mapNotNull { uri -> + FileRecord.fromUri(uri, context) + } + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + if (selectMultiple) { + getMultipleDocuments.launch(selectedFilter.mimeType) + } else { + getSingleDocument.launch(selectedFilter.mimeType) + } + }, + ) { + Text(if (selectMultiple) "Select Files" else "Select File") + } + }, + ) { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + item { + ListItem( + headlineContent = { Text("File type filter") }, + supportingContent = { + Text(selectedFilter.name) + }, + trailingContent = { + val scrollState = rememberScrollState() + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart), + ) { + IconButton(onClick = { expanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_filter_alt_24), + contentDescription = "Localized description", + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + scrollState = scrollState, + ) { + FileType.entries.forEach { fileType -> + DropdownMenuItem( + text = { Text(fileType.name) }, + onClick = { selectedFilter = fileType }, + leadingIcon = { + if (selectedFilter == fileType) { + Icon( + Icons.Outlined.Check, + contentDescription = "Selected", + ) + } + }, + ) + } + } + LaunchedEffect(expanded) { + if (expanded) { + // Scroll to show the bottom menu items. + scrollState.scrollTo(scrollState.maxValue) + } + } + } + }, + ) + HorizontalDivider() + } + item { + ListItem( + headlineContent = { Text("Select multiple files?") }, + trailingContent = { + Switch( + modifier = Modifier.semantics { + contentDescription = "Select multiple files" + }, + checked = selectMultiple, + onCheckedChange = { selectMultiple = it }, + thumbContent = { + if (selectMultiple) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + }, + ) + }, + ) + HorizontalDivider() + } + items(selectedFiles) { file -> + when (file.fileType) { + FileType.Image -> ImageFileCard(file) + FileType.Video -> VideoFileCard(file) + FileType.Audio -> AudioFileCard(file) + FileType.Text -> TextFileCard(file) + FileType.Pdf -> PdfFileCard(file) + FileType.Any -> BinaryFileCard(file) + } + } + } + } +} \ No newline at end of file diff --git a/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileCard.kt b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileCard.kt new file mode 100644 index 00000000..e8e3be79 --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileCard.kt @@ -0,0 +1,455 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.platform.storage.storageaccessframework.shared + +import android.content.Context +import android.net.Uri +import android.text.format.Formatter +import androidx.annotation.DrawableRes +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.width +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.example.platform.storage.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader + +@Composable +fun FileCard( + file: FileRecord, + @DrawableRes iconResourceId: Int, + contentPreview: @Composable (() -> Unit)? = null, +) { + val sizeLabel = Formatter.formatShortFileSize(LocalContext.current, file.size) + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Icon( + painter = painterResource(iconResourceId), + contentDescription = null, + modifier = Modifier.size(42.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + file.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text("$sizeLabel ยท ${file.mimeType}", style = MaterialTheme.typography.bodyMedium) + + if (contentPreview != null) { + contentPreview() + } + } + } + } +} + +@Composable +fun ImageFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_image_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + var loadThumbnail by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(16.dp)) + if (loadThumbnail) { + AsyncImage( + model = file.uri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(1f), + ) + } else { + Button(onClick = { loadThumbnail = true }) { + Text("Load thumbnail") + } + } + } + } +} + +@Composable +fun VideoFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_video_file_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + var loadThumbnail by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(16.dp)) + if (loadThumbnail) { + AsyncImage( + model = file.uri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(1f), + ) + } else { + Button(onClick = { loadThumbnail = true }) { + Text("Load thumbnail") + } + } + } + } +} + +@Composable +fun AudioFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_audio_file_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + Spacer(modifier = Modifier.height(16.dp)) + + var loadFilePreview by remember { mutableStateOf(false) } + val filePreview by loadRawFileContent(file.uri, LocalContext.current, loadFilePreview) + + Spacer(modifier = Modifier.height(16.dp)) + when (filePreview) { + FilePreview.NotLoadedYet -> { + Button(onClick = { loadFilePreview = true }) { + Text("Display first 10 bytes") + } + } + + FilePreview.Loading -> { + Text("Loading...") + } + + is FilePreview.Loaded -> { + Text("First 10 bytes: ${(filePreview as FilePreview.Loaded).content}") + } + + is FilePreview.Error -> { + Text("Error: ${(filePreview as FilePreview.Error).throwable.message}") + } + } + } + } +} + +@Composable +fun TextFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_description_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + Spacer(modifier = Modifier.height(16.dp)) + + var loadFilePreview by remember { mutableStateOf(false) } + val filePreview by loadTextFileContent(file.uri, LocalContext.current, loadFilePreview) + + Spacer(modifier = Modifier.height(16.dp)) + when (filePreview) { + FilePreview.NotLoadedYet -> { + Button(onClick = { loadFilePreview = true }) { + Text("Read first 300 characters") + } + } + + FilePreview.Loading -> { + Text("Loading...") + } + + is FilePreview.Loaded -> { + Text("First 300 chars: ${(filePreview as FilePreview.Loaded).content}") + } + + is FilePreview.Error -> { + Text("Error: ${(filePreview as FilePreview.Error).throwable.message}") + } + } + } + } +} + +@Composable +fun PdfFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_picture_as_pdf_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + Spacer(modifier = Modifier.height(16.dp)) + + var loadFilePreview by remember { mutableStateOf(false) } + val filePreview by loadRawFileContent(file.uri, LocalContext.current, loadFilePreview) + + Spacer(modifier = Modifier.height(16.dp)) + when (filePreview) { + FilePreview.NotLoadedYet -> { + Button(onClick = { loadFilePreview = true }) { + Text("Display first 10 bytes") + } + } + + FilePreview.Loading -> { + Text("Loading...") + } + + is FilePreview.Loaded -> { + Text("First 10 bytes: ${(filePreview as FilePreview.Loaded).content}") + } + + is FilePreview.Error -> { + Text("Error: ${(filePreview as FilePreview.Error).throwable.message}") + } + } + } + } +} + +@Composable +fun BinaryFileCard(file: FileRecord, contentPreview: @Composable (() -> Unit)? = null) { + FileCard(file, R.drawable.ic_insert_drive_file_24) { + if (contentPreview != null) { + Spacer(modifier = Modifier.height(16.dp)) + contentPreview() + } else { + Spacer(modifier = Modifier.height(16.dp)) + + var loadFilePreview by remember { mutableStateOf(false) } + val filePreview by loadRawFileContent(file.uri, LocalContext.current, loadFilePreview) + + Spacer(modifier = Modifier.height(16.dp)) + when (filePreview) { + FilePreview.NotLoadedYet -> { + Button(onClick = { loadFilePreview = true }) { + Text("Display first 10 bytes") + } + } + + FilePreview.Loading -> { + Text("Loading...") + } + + is FilePreview.Loaded -> { + Text("First 10 bytes: ${(filePreview as FilePreview.Loaded).content}") + } + + is FilePreview.Error -> { + Text("Error: ${(filePreview as FilePreview.Error).throwable.message}") + } + } + } + } +} + +@Preview +@Composable +fun ImageFileCard_Preview() { + ImageFileCard( + FileRecord( + Uri.EMPTY, + "AmazingPhoto.png", + 345_000, + "image/png", + FileType.Image, + ), + ) +} + +@Preview +@Composable +fun VideoFileCard_Preview() { + VideoFileCard( + FileRecord( + Uri.EMPTY, + "All hands - meeting recording.mp4", + 1_234_567_890, + "video/mp4", + FileType.Video, + ), + ) +} + +@Preview +@Composable +fun AudioFileCard_Preview() { + AudioFileCard( + FileRecord( + Uri.EMPTY, + "Queen - We will rock you.mp3", + 5_432_100, + "audio/mp3", + FileType.Audio, + ), + ) +} + +@Preview +@Composable +fun TextFileCard_Preview() { + TextFileCard( + FileRecord( + Uri.EMPTY, + "Android Jetpack Compose.txt", + 5_678, + "text/plain", + FileType.Text, + ), + ) +} + +@Preview +@Composable +fun PdfFileCard_Preview() { + PdfFileCard( + FileRecord( + Uri.EMPTY, + "Android Jetpack Compose.pdf", + 1_234_567, + "application/pdf", + FileType.Pdf, + ), + ) +} + +@Preview +@Composable +fun BinaryFileCard_Preview() { + BinaryFileCard( + FileRecord( + Uri.EMPTY, + "binary.bin", + 78_420_968, + "application/octet-stream", + FileType.Any, + ), + ) +} + +sealed interface FilePreview { + data object NotLoadedYet : FilePreview + data object Loading : FilePreview + + @JvmInline + value class Loaded(val content: String) : FilePreview + + @JvmInline + value class Error(val throwable: Throwable) : FilePreview +} + +@Composable +fun loadTextFileContent( + uri: Uri, + context: Context, + loadContent: Boolean = false, + numberOfChars: Int = 300, +): State { + return produceState(FilePreview.NotLoadedYet, uri, loadContent, numberOfChars) { + withContext(Dispatchers.IO) { + if (!loadContent) { + return@withContext + } + + value = FilePreview.Loading + + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + val buffer = CharArray(numberOfChars) + val charsRead = reader.read(buffer) + + value = if (charsRead > 0) { + FilePreview.Loaded(String(buffer, 0, charsRead)) + } else { + FilePreview.Error(Exception("End of file or no characters available.")) + } + } + } ?: run { + value = FilePreview.Error(Exception("Failed to open InputStream")) + } + } + } +} + +@Composable +fun loadRawFileContent( + uri: Uri, + context: Context, + loadContent: Boolean = false, + numberOfBytes: Int = 10, +): State { + return produceState(FilePreview.NotLoadedYet, uri, loadContent, numberOfBytes) { + withContext(Dispatchers.IO) { + if (!loadContent) { + return@withContext + } + + value = FilePreview.Loading + + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val buffer = ByteArray(numberOfBytes) + val bytesRead = inputStream.read(buffer) + + value = if (bytesRead > 0) { + FilePreview.Loaded(buffer.joinToString(" | ") { byte -> byte.toString() }) + } else { + FilePreview.Error(Exception("End of InputStream or no bytes available")) + } + } ?: run { + value = FilePreview.Error(Exception("Failed to open InputStream")) + } + } + } +} \ No newline at end of file diff --git a/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileRecord.kt b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileRecord.kt new file mode 100644 index 00000000..71cb83cc --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileRecord.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.platform.storage.storageaccessframework.shared + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class FileRecord( + val uri: Uri, + val name: String, + val size: Long, + val mimeType: String, + val fileType: FileType, +) { + companion object { + suspend fun fromUri(uri: Uri, context: Context): FileRecord? = withContext(Dispatchers.IO) { + val mimeType = context.contentResolver.getType(uri) ?: return@withContext null + val fileType = when { + mimeType.startsWith("image/") -> FileType.Image + mimeType.startsWith("video/") -> FileType.Video + mimeType.startsWith("audio/") -> FileType.Audio + mimeType.startsWith("text/") -> FileType.Text + mimeType == "application/pdf" -> FileType.Pdf + else -> FileType.Any + } + + val projection = arrayOf( + OpenableColumns.DISPLAY_NAME, + OpenableColumns.SIZE, + ) + + val cursor = context.contentResolver.query( + uri, + projection, + null, + null, + null, + ) ?: return@withContext null + + cursor.use { + if (!cursor.moveToFirst()) { + return@withContext null + } + + return@use FileRecord( + uri = uri, + name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)), + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)), + mimeType = mimeType, + fileType = fileType, + ) + } + } + } +} + +enum class FileType(val mimeType: String) { + Image("image/*"), + Video("video/*"), + Audio("audio/*"), + Text("text/*"), + Pdf("application/pdf"), + Any("*/*"); +} \ No newline at end of file diff --git a/samples/storage/src/main/res/drawable/ic_audio_file_24.xml b/samples/storage/src/main/res/drawable/ic_audio_file_24.xml new file mode 100644 index 00000000..de1af865 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_audio_file_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_description_24.xml b/samples/storage/src/main/res/drawable/ic_description_24.xml new file mode 100644 index 00000000..a7eeaf50 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_description_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_filter_alt_24.xml b/samples/storage/src/main/res/drawable/ic_filter_alt_24.xml new file mode 100644 index 00000000..783f45d1 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_filter_alt_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_image_24.xml b/samples/storage/src/main/res/drawable/ic_image_24.xml new file mode 100644 index 00000000..f6f60602 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_image_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_insert_drive_file_24.xml b/samples/storage/src/main/res/drawable/ic_insert_drive_file_24.xml new file mode 100644 index 00000000..afcc07c2 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_insert_drive_file_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_picture_as_pdf_24.xml b/samples/storage/src/main/res/drawable/ic_picture_as_pdf_24.xml new file mode 100644 index 00000000..c72be0d9 --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_picture_as_pdf_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/storage/src/main/res/drawable/ic_video_file_24.xml b/samples/storage/src/main/res/drawable/ic_video_file_24.xml new file mode 100644 index 00000000..680625da --- /dev/null +++ b/samples/storage/src/main/res/drawable/ic_video_file_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/samples/user-interface/draganddrop/src/main/java/com/example/platform/ui/draganddrop/DragAndDrop.kt b/samples/user-interface/draganddrop/src/main/java/com/example/platform/ui/draganddrop/DragAndDrop.kt index 1f9cba82..c453fd6b 100644 --- a/samples/user-interface/draganddrop/src/main/java/com/example/platform/ui/draganddrop/DragAndDrop.kt +++ b/samples/user-interface/draganddrop/src/main/java/com/example/platform/ui/draganddrop/DragAndDrop.kt @@ -50,12 +50,6 @@ private const val TAG = "DragDropSample" private const val MAX_LENGTH = 200 @RequiresApi(24) -/*@Sample( - name = "Drag and Drop", - description = "Demonstrates basic Drag and Drop functionality.", - documentation = "https://developer.android.com/develop/ui/views/touch-and-input/drag-drop", -) - */ @Deprecated("The new sample include segregated examples individually for Views, DragAndDropHelper, RichContentReceiver along with Compose") class DragAndDropActivity : AppCompatActivity() { diff --git a/samples/user-interface/haptics/build.gradle.kts b/samples/user-interface/haptics/build.gradle.kts index 88a2d23a..7869ed57 100644 --- a/samples/user-interface/haptics/build.gradle.kts +++ b/samples/user-interface/haptics/build.gradle.kts @@ -36,6 +36,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) + implementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.lifecycle.viewmodel.compose)