Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Manejo de Permisos en Android
Docente: Carlos Andrés Florez V.
Una aplicación móvil a menudo necesita acceder a recursos sensibles del dispositivo, como la cámara, el micrófono o la ubicación del usuario. Para proteger la privacidad del usuario, Android implementa un sistema de permisos que requiere que las aplicaciones soliciten permiso explícito para acceder a estos recursos.
Para manejar los permisos en Android, se utilizan varias funciones y clases proporcionadas por el framework de Android. A continuación, se describen algunas de las más importantes:
ActivityResultContracts.RequestPermission facilita la solicitud de permisos en tiempo de ejecución. Esta clase forma parte del sistema de resultados de actividades y permite solicitar permisos de manera sencilla y manejar la respuesta del usuario.ActivityResultContracts.GetContent permite a las aplicaciones solicitar contenido del dispositivo. Esto es últil para simplificar el acceso a archivos y otros recursos una vez que los permisos han sido concedidos.ContextCompat.checkSelfPermission() se utiliza para verificar si la aplicación ya tiene un permiso específico concedido. Esta función devuelve un valor que indica si el permiso está concedido o denegado.El flujo de trabajo típico para solicitar permisos en una aplicación Android es el siguiente:
ContextCompat.checkSelfPermission().ActivityResultContracts.RequestPermission.ActivityResultContracts.GetContent() para solicitar archivos sin preocuparse por los permisos subyacentes ya que se asume que el usuario ha concedido el permiso necesario.El siguiente diagrama ilustra el flujo de trabajo para solicitar permisos en una aplicación Android:
Algunos de los permisos más comunes que las aplicaciones pueden solicitar incluyen:
android.permission.CAMERAandroid.permission.ACCESS_FINE_LOCATION y android.permission.ACCESS_COARSE_LOCATIONandroid.permission.READ_EXTERNAL_STORAGE y android.permission.WRITE_EXTERNAL_STORAGEandroid.permission.RECORD_AUDIOandroid.permission.READ_CONTACTS y android.permission.WRITE_CONTACTSandroid.permission.SEND_SMS y android.permission.RECEIVE_SMSandroid.permission.READ_CALENDAR y android.permission.WRITE_CALENDARandroid.permission.INTERNETandroid.permission.ACCESS_NETWORK_STATEDesde Android 6.0 (API nivel 23), los permisos se dividen en dos categorías: permisos normales y permisos peligrosos. Los permisos normales se conceden automáticamente, mientras que los permisos peligrosos requieren la aprobación explícita del usuario en tiempo de ejecución.
Un ejemplo de permiso peligroso es el acceso a la ubicación del usuario, mientras que un permiso normal es el acceso a Internet. Sin embargo, siempre se define el permiso en el archivo AndroidManifest.xml, independientemente de su categoría.
Con base en lo explicado anteriormente, a continuación se presenta un ejemplo completo de una pantalla de perfil que maneja permisos para acceder a la cámara y la galería de imágenes utilizando Jetpack Compose.
Modifique o cree el archivo ProfileScreen.kt en el paquete com.example.demoapp.features.user.profile con el siguiente código:
package com.example.demoapp.features.user.profile
// Importaciones necesarias ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
padding: PaddingValues, // Padding del Scaffold
userId: String = "1", // ID de usuario de ejemplo
snackbarHostState: SnackbarHostState, // Para mostrar mensajes, si pertenece a un Scaffold
viewModel: ProfileViewModel = hiltViewModel() // ViewModel inyectado con Hilt
) {
// El contexto se refiere a la actividad o aplicación actual
val context = LocalContext.current
// Coroutine scope para lanzar tareas asíncronas, como mostrar Snackbars
val scope = rememberCoroutineScope()
// Estados del ViewModel
val user by viewModel.user.collectAsState()
val isEditMode by viewModel.isEditMode.collectAsState()
val photoUri by viewModel.photoUri.collectAsState()
val updateResult by viewModel.updateResult.collectAsState()
// Estado del Bottom Sheet
val bottomSheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
// URI temporal para la foto de cámara
var tempCameraUri by remember { mutableStateOf<Uri?>(null) }
// Launcher para seleccionar de galería
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
// Usar el URI seleccionado de la galería
uri?.let { viewModel.onPhotoSelected(it) }
}
// Launcher para tomar foto
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success: Boolean ->
if (success) {
// Usar el URI temporal para la foto tomada
tempCameraUri?.let { viewModel.onPhotoSelected(it) }
}
}
// Launcher para pedir permiso de cámara
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
// Crear URI temporal para la foto
tempCameraUri = createTempImageUri(context)
// Lanzar cámara con el URI temporal, finalmente la foto se maneja en cameraLauncher
tempCameraUri?.let { cameraLauncher.launch(it) }
} else {
scope.launch {
snackbarHostState.showSnackbar("Se necesita permiso de cámara para tomar fotos")
}
}
}
// Cargar usuario al iniciar
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
// Mostrar resultado de actualización
LaunchedEffect(updateResult) {
when (updateResult) {
is RequestResult.Success -> {
snackbarHostState.showSnackbar(
(updateResult as RequestResult.Success).message
)
viewModel.clearResult()
}
is RequestResult.Failure -> {
snackbarHostState.showSnackbar(
(updateResult as RequestResult.Failure).errorMessage
)
viewModel.clearResult()
}
null -> {}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
// Imagen de perfil circular
ProfileImage(
photoUri = photoUri,
isEditMode = isEditMode,
onEditClick = { showBottomSheet = true }
)
// Email (solo lectura)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = user?.email ?: "",
onValueChange = {},
label = { Text(text = stringResource(R.string.login_email_label)) },
readOnly = true,
enabled = false
)
// Nombre
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.name.value,
onValueChange = { viewModel.name.onChange(it) },
label = { Text(text = stringResource(R.string.txt_name)) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null
)
},
isError = viewModel.name.error != null,
supportingText = viewModel.name.error?.let { error ->
{ Text(text = error) }
},
enabled = isEditMode,
singleLine = true
)
// Ciudad (Dropdown)
DropdownMenu(
label = stringResource(R.string.txt_city),
list = viewModel.cities,
icon = Icons.Outlined.Place,
onValueChange = { viewModel.city.onChange(it) },
enabled = isEditMode,
value = viewModel.city.value
)
// Dirección
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.address.value,
onValueChange = { viewModel.address.onChange(it) },
label = { Text(text = stringResource(R.string.txt_address)) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Place,
contentDescription = null
)
},
isError = viewModel.address.error != null,
supportingText = viewModel.address.error?.let { error ->
{ Text(text = error) }
},
enabled = isEditMode,
singleLine = true
)
// Teléfono
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.phone.value,
onValueChange = { viewModel.phone.onChange(it) },
label = { Text(text = stringResource(R.string.txt_phone)) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Phone,
contentDescription = null
)
},
isError = viewModel.phone.error != null,
supportingText = viewModel.phone.error?.let { error ->
{ Text(text = error) }
},
enabled = isEditMode,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true
)
// Botones de acción
if (isEditMode) {
// Guardar cambios
Button(
onClick = { viewModel.saveChanges() },
modifier = Modifier.fillMaxWidth(),
enabled = viewModel.isFormValid
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(R.string.txt_save))
}
// Cancelar edición
OutlinedButton(
onClick = { viewModel.cancelEdit() },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(R.string.txt_cancel))
}
} else {
Button(
onClick = { viewModel.toggleEditMode() },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(R.string.txt_edit))
}
}
}
}
// Bottom Sheet para elegir cámara o galería
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = bottomSheetState
) {
ImagePickerBottomSheet(
onCameraClick = {
showBottomSheet = false
// Solicitar permiso de cámara y luego lanzar cámara
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
onGalleryClick = {
showBottomSheet = false
// Lanzar selector de galería
galleryLauncher.launch("image/*")
},
onDismiss = { showBottomSheet = false }
)
}
}
}
La idea es que inicialmente se muestre la pantalla de perfil con los datos del usuario. Al hacer clic en el ícono de edición se habilitan los campos para editar. Al hacer clic en el ícono de la cámara, se muestra un bottom sheet con las opciones para tomar una foto o seleccionar una imagen de la galería. Si el usuario elige tomar una foto, se solicita el permiso de cámara si no ha sido concedido previamente. Una vez que se obtiene la imagen, se actualiza la foto de perfil.
Un bottom sheet es un componente de UI que se desliza desde la parte inferior de la pantalla para mostrar opciones adicionales o información. En este caso, se utiliza para permitir al usuario elegir entre tomar una foto con la cámara o seleccionar una imagen de la galería.
⚠️ Importante: Asegúrese de agregar los textos correspondientes en el archivo
strings.xmlpara las cadenas utilizadas en la interfaz de usuario.
Agregue las siguientes funciones auxiliares en el mismo archivo o en un archivo separado según su preferencia:
El composable ProfileImage muestra la imagen de perfil del usuario. Si no hay una imagen disponible, se muestra un ícono predeterminado. Cuando la pantalla está en modo edición, se muestra un botón de cámara para cambiar la foto.
@Composable
private fun ProfileImage(
photoUri: Uri?,
isEditMode: Boolean,
onEditClick: () -> Unit
) {
val imageSize = 140.dp
Box(
contentAlignment = Alignment.Center
) {
if (photoUri != null) {
// Mostrar la imagen seleccionada desde el URI
AsyncImage(
model = photoUri,
contentDescription = stringResource(R.string.profile_image_description),
modifier = Modifier
.size(imageSize)
.clip(CircleShape)
.border(
width = 3.dp,
color = MaterialTheme.colorScheme.primary,
shape = CircleShape
)
.then(
if (isEditMode) {
Modifier.clickable { onEditClick() }
} else {
Modifier
}
),
contentScale = ContentScale.Crop
)
} else {
// Mostrar ícono predeterminado si no hay imagen
Icon(
imageVector = Icons.Rounded.AccountCircle,
contentDescription = stringResource(R.string.profile_image_description),
modifier = Modifier
.size(imageSize)
.then(
if (isEditMode) {
Modifier.clickable { onEditClick() }
} else {
Modifier
}
),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Botón de cámara cuando está en modo edición
if (isEditMode) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.clickable { onEditClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.CameraAlt,
contentDescription = stringResource(R.string.change_photo),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
)
}
}
}
}
El composable ImagePickerBottomSheet define el contenido del bottom sheet que permite al usuario elegir entre tomar una foto o seleccionar una imagen de la galería.
@Composable
private fun ImagePickerBottomSheet(
onCameraClick: () -> Unit,
onGalleryClick: () -> Unit,
onDismiss: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text(
text = stringResource(R.string.select_image),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
ImagePickerOption(
icon = Icons.Outlined.PhotoCamera,
text = stringResource(R.string.take_photo),
onClick = onCameraClick
)
ImagePickerOption(
icon = Icons.Outlined.Image,
text = stringResource(R.string.choose_from_gallery),
onClick = onGalleryClick
)
TextButton(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(stringResource(R.string.cancel))
}
}
}
El composable ImagePickerOption define una opción individual en el bottom sheet con un ícono y texto. La idea es reutilizar este componente para cada opción disponible.
@Composable
private fun ImagePickerOption(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
Finalmente, la función createTempImageUri crea un URI temporal para almacenar la foto tomada con la cámara. Esto es necesario para que la cámara pueda guardar la imagen en un lugar accesible para la aplicación.
private fun createTempImageUri(context: Context): Uri {
val tempFile = File.createTempFile(
"profile_photo_",
".jpg",
context.cacheDir
).apply {
createNewFile()
deleteOnExit()
}
return FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile
)
}
Para que la función createTempImageUri funcione correctamente, es necesario configurar un FileProvider en el archivo AndroidManifest.xml. Agregue el siguiente proveedor dentro de la etiqueta <application>:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
Agregue el permiso de cámara en el archivo AndroidManifest.xml, esto es necesario para solicitar acceso a la cámara del dispositivo:
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
Cree un archivo XML llamado file_paths.xml en la carpeta res/xml/ con el siguiente contenido:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cache"
path="." />
</paths>
La idea es que este archivo define las rutas accesibles a través del FileProvider, en este caso, la carpeta de caché de la aplicación.
Cree o modifique el ProfileViewModel para manejar la lógica de carga y actualización del usuario, así como el manejo de la foto seleccionada. Asegúrese de que el ViewModel tenga las funciones necesarias para cargar los datos del usuario, guardar los cambios y actualizar la foto de perfil. Recuerde que el ViewModel debe estar en el mismo paquete que la pantalla de perfil.
Puede utilizar el siguiente código como referencia:
package com.example.demoapp.features.user.profile
// Importaciones necesarias ...
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: UserRepository,
private val resources: ResourceProvider // Para acceder a recursos de strings
) : ViewModel() {
private val _updateResult = MutableStateFlow<RequestResult?>(null)
val updateResult: StateFlow<RequestResult?> = _updateResult.asStateFlow()
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
private val _isEditMode = MutableStateFlow(false)
val isEditMode: StateFlow<Boolean> = _isEditMode.asStateFlow()
val cities = listOf("Ciudad 1", "Ciudad 2", "Ciudad 3")
val name = ValidatedField("") { value ->
when {
value.isEmpty() -> resources.getString(R.string.error_name_required)
value.length > 30 -> resources.getString(R.string.error_name_length)
else -> null
}
}
val city = ValidatedField("") { value ->
if (value.isEmpty()) resources.getString(R.string.error_city_required) else null
}
val address = ValidatedField("") { value ->
if (value.isEmpty()) resources.getString(R.string.error_address_required) else null
}
val phone = ValidatedField("") { value ->
when {
value.isEmpty() -> resources.getString(R.string.error_phone_required)
!value.matches(Regex("^[0-9]{10}$")) -> resources.getString(R.string.error_phone_invalid)
else -> null
}
}
private val _photoUri = MutableStateFlow<Uri?>(null)
val photoUri: StateFlow<Uri?> = _photoUri.asStateFlow()
val isFormValid: Boolean
get() = name.isValid && address.isValid && city.isValid && phone.isValid
// Cargar usuario desde el repositorio
fun loadUser(userId: String) {
// Se recupera el usuario desde el repositorio
val foundUser = repository.findById(userId)
// Si se encuentra, se actualizan los campos
foundUser?.let { user ->
_user.value = user
// Actualizar campos del formulario con los datos del usuario
name.onChange(user.name)
city.onChange(user.city)
address.onChange(user.address)
phone.onChange(user.phoneNumber ?: "")
user.profilePictureUrl?.let { _photoUri.value = it.toUri() }
}
}
fun toggleEditMode() {
_isEditMode.value = !_isEditMode.value
}
fun onPhotoSelected(uri: Uri?) {
_photoUri.value = uri
}
// Guardar cambios del perfil en el repositorio
fun saveChanges() {
if (isFormValid) {
// Actualizar el usuario en el repositorio
_user.value?.let { currentUser ->
val updatedUser = currentUser.copy(
name = name.value,
city = city.value,
address = address.value,
phoneNumber = phone.value,
profilePictureUrl = _photoUri.value?.toString()
)
repository.update(updatedUser)
_user.value = updatedUser
_updateResult.value = RequestResult.Success(resources.getString(R.string.profile_updated_successfully))
_isEditMode.value = false
}
}
}
// Cancelar edición y restaurar valores originales
fun cancelEdit() {
_user.value?.let { user ->
name.onChange(user.name)
city.onChange(user.city)
address.onChange(user.address)
phone.onChange(user.phone ?: "")
_photoUri.value = user.image?.toUri()
}
_isEditMode.value = false
}
fun clearResult() {
_updateResult.value = null
}
}
Observe que el ViewModel utiliza un repositorio para cargar y actualizar los datos del usuario. Además, maneja la validación de los campos del formulario y el estado de la foto seleccionada. No se maneja directamente la lógica de permisos, ya que esto se realiza en la pantalla de perfil dado que involucra la interacción con el usuario.
⚠️ Importante: Asegúrese de agregar los textos correspondientes en el archivo
strings.xmlpara las cadenas utilizadas en el ViewModel. Además, adapte el repositorio y el modelo de usuario según la estructura de su proyecto.
Ejecute la aplicación en un dispositivo o emulador Android. Navegue a la pantalla de perfil y pruebe las funcionalidades de edición, incluyendo la captura de fotos con la cámara y la selección de imágenes desde la galería. Asegúrese de que los permisos se soliciten correctamente y que las imágenes se actualicen en el perfil del usuario.
Investigue como subir la imagen seleccionada a un servidor remoto o servicio de almacenamiento en la nube (como Firebase Storage, Cloudinary o AWS S3) al momento de guardar los cambios del perfil. Implemente esta funcionalidad en el ProfileViewModel y asegúrese de manejar los posibles errores durante la carga de la imagen.
Profundizar sobre el concepto de scope en Kotlin Coroutines y cómo se utiliza en el contexto de Jetpack Compose para manejar tareas asíncronas, como la solicitud de permisos y la carga de imágenes.