diff --git a/samples/README.md b/samples/README.md index 3c26a020..64fe1cd8 100644 --- a/samples/README.md +++ b/samples/README.md @@ -48,6 +48,8 @@ Drag and Drop using the views This sample demonstrates editing an UltraHDR image and the resulting gainmap as well. Spatial edit operations like crop, rotate, scale are supported - [Find devices sample](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/FindBLEDevicesSample.kt): This example will demonstrate how to scanning for Low Energy Devices +- [GetDocument](storage/src/main/java/com/example/platform/storage/storageaccessframework/GetDocument.kt): +Open a document using the Storage Access Framework - [Haptics - 1. Vibration effects](user-interface/haptics/src/main/java/com/example/platform/ui/haptics/Haptics.kt): Shows various vibration effects. - [Hyphenation](user-interface/text/src/main/java/com/example/platform/ui/text/Hyphenation.kt): diff --git a/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetDocument.kt b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetDocument.kt new file mode 100644 index 00000000..f23d9623 --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/GetDocument.kt @@ -0,0 +1,201 @@ +/* + * 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.filled.FilterAlt +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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 com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.launch + +@Sample( + name = "GetDocument", + description = "Open a document using the Storage Access Framework", + documentation = "https://developer.android.com/training/data-storage/shared/documents-files#open-file", +) +@Composable +fun GetDocument() { + 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( + Icons.Default.FilterAlt, + 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..950f1547 --- /dev/null +++ b/samples/storage/src/main/java/com/example/platform/storage/storageaccessframework/shared/FileCard.kt @@ -0,0 +1,460 @@ +/* + * 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.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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.VideoFile +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader + +@Composable +fun FileCard( + file: FileRecord, + icon: ImageVector, + 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( + imageVector = icon, + 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, Icons.Default.Image) { + 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, Icons.Default.VideoFile) { + 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, Icons.Default.AudioFile) { + 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, Icons.Default.Description) { + 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, Icons.Default.PictureAsPdf) { + 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, Icons.AutoMirrored.Filled.InsertDriveFile) { + 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