Saltar al contenido
K
Arquitectura en iOSPaso a paso35 min de lectura · 15 min de máquina Básico

Permisos en iOS

Al terminar: Declarar permisos en Info.plist y mostrar una notificación.

Al terminar, Marcador sabrá pedir permiso para avisarte: en la pantalla del partido habrá un botón «Activar avisos de gol», verás el diálogo real del sistema, el botón cambiará a «Avisos de gol: activados» y el registro de Xcode dejará escrito qué respondiste. Los banners «¡Gol!» llegan en la lección siguiente.

Importa porque en iOS ninguna notificación llega sin que el usuario la autorice, y porque los demás permisos (cámara, ubicación, fotos) se declaran de otra manera: en el Info del target, que es la ficha con la que la app se presenta al sistema. Las notificaciones locales solo necesitan una de las dos mitades. No instala ningún paquete: UserNotifications viene con iOS y basta con escribir import UserNotifications.

Temas: la ficha Info.plist, permisos en tiempo de ejecución, capabilities y entitlements.

Trabajas en tu app, ~/Marcador-Swift/MarcadorApp, con MarcadorApp.xcodeproj abierto en Xcode. Los ficheros de código están en la carpeta MarcadorApp del Project navigator (Cmd+1), repartidos en carpetas por funcionalidad; las pruebas, en MarcadorAppTests y MarcadorAppUITests.

Declarar y pedir: dos cosas distintas

Un permiso es una autorización que el sistema exige antes de que la app toque algo delicado. En iOS hay tres piezas que se confunden con facilidad:

PiezaQué esDónde viveEjemplo
Info.plistUna lista de pares clave-valor que describe la app al sistema: nombre, orientaciones y el texto del diálogo de cada permisoEn el target de la appNSCameraUsageDescription = «Para escanear el acta»
Petición en ejecuciónUna llamada de código que abre el diálogo del sistema y devuelve la respuestaEn tu códigorequestAuthorization(options:)
Capability y entitlementUn servicio que la app declara y que Apple firma con ella. Una capability es una casilla del target; un entitlement es lo que esa casilla escribe en un fichero .entitlementsEn el target y en tu cuenta de desarrolladorNotificaciones remotas (aps-environment)

La regla, según lo que quieras usar:

Quieres…¿Info.plist?¿Petición en ejecución?¿Capability?
Cámara, micrófono, fotos, ubicaciónSí: una clave NS…UsageDescription con el motivoSíNo
Notificaciones locales (Marcador)NoSíNo
Notificaciones remotas (push)NoSíSí: Push Notifications y una cuenta de pago

El Info de tu target, por dentro

Marcador no tiene ningún fichero Info.plist suelto: Xcode lo genera en cada compilación a partir de ajustes del target (el GENERATE_INFOPLIST_FILE = YES que viste en Tu primera app de iOS). Para ver el resultado, ejecuta la app una vez (Cmd+R) y, con el simulador arrancado, pídele a la Terminal el Info.plist de la app instalada:

plutil -p "$(xcrun simctl get_app_container booted com.sebastiannarvaez.marcador app)/Info.plist"

simctl get_app_container … app imprime la ruta de la app instalada y plutil -p escribe un .plist como texto legible. Entre las claves verás CFBundleDisplayName => "Marcador" (el Display Name que escribiste en el paso de firma de Tu primera app de iOS), CFBundleIdentifier => "com.sebastiannarvaez.marcador" y UIApplicationSceneManifest. No hay ninguna clave de notificaciones, y es lo correcto.

Dónde se edita esa ficha: selecciona el proyecto MarcadorApp (el icono azul, arriba del Project navigator) → el target MarcadorApp → la pestaña Info. Es una lista de claves y valores; el botón + añade una. Cada clave que añades queda guardada como un ajuste del target llamado INFOPLIST_KEY_<nombre>, que ves en la pestaña Build Settings.

La otra pestaña que conviene reconocer es Signing & Capabilities, junto a Info: ahí está la firma (el Team que elegiste en el paso de firma, o ninguno) y el botón + Capability, que añade capabilities como Push Notifications o Background Modes. Marcador no añade ninguna.

Pruébalo con un permiso que Marcador no usa. Es solo para mirar cómo queda en la ficha, y lo quitarás al final:

  1. En la pestaña Info del target, pulsa + y añade la clave Privacy - Camera Usage Description con el valor «Para escanear el acta del partido». Es el nombre legible de NSCameraUsageDescription; si tu Xcode lo muestra distinto, busca la palabra Camera.
  2. Ejecuta la app otra vez (Cmd+R) y repite el plutil -p de arriba: ahora aparece NSCameraUsageDescription => "Para escanear el acta del partido".
  3. En Build Settings, busca INFOPLIST_KEY_NSCamera: es el mismo dato visto desde otro sitio, y por eso la pestaña Info y los ajustes del target son dos vistas de lo mismo.
  4. Quita la clave al terminar (selecciona su fila en la pestaña Info y bórrala) y vuelve a ejecutar: tu app tiene que quedar sin ella, porque Marcador no usa la cámara.

Dos consecuencias de esa cita. Primera: el sistema pregunta una sola vez. Si el usuario dice que no, requestAuthorization ya no muestra el diálogo, y lo único que puedes hacer es explicarle que puede cambiarlo en Ajustes. Segunda: pídelo cuando el usuario entienda para qué sirve; por eso Marcador lo pide con un botón y no al abrir la app.

Ejemplo mínimo: pedir el permiso

ejemplo mínimo: pedir el permiso (con comentarios)
import UserNotifications
 
// El centro compartido de la app: por aquí pasa todo lo de notificaciones.
let centro = UNUserNotificationCenter.current()
 
func activarAvisos() async {
    do {
        // Suspende esta función hasta que el usuario responda al diálogo.
        let concedido = try await centro.requestAuthorization(options: [.alert, .sound])
        print(concedido ? "avisos activados" : "avisos denegados")
    } catch {
        print("no se pudo pedir el permiso: \(error)")
    }
}

No lo ejecutes en el Playground: el diálogo es del sistema de un iPhone, y lo verás funcionar en el paso 3. requestAuthorization es async (lo viste en async/await y Task): la respuesta llega cuando el usuario toca un botón, no antes. options dice qué formas de aviso pides: .alert (banner), .sound y .badge (el número en el icono). Devuelve un Bool: true si concede.

Para leer el estado sin mostrar nada existe notificationSettings(), y su authorizationStatus distingue .notDetermined (aún no se le ha preguntado), .denied, .authorized y .provisional (una autorización de prueba: las notificaciones llegan en silencio al centro de notificaciones, sin banner ni sonido; Marcador no la pide):

ejemplo mínimo: leer el permiso sin preguntar (con comentarios)
import UserNotifications
 
// ¿Qué respondió el usuario? Se lee sin mostrar ningún diálogo.
func avisosActivos() async -> Bool {
    let ajustes = await UNUserNotificationCenter.current().notificationSettings()   // el estado guardado
    // .authorized (dijo que sí) o .provisional (permiso de prueba, en silencio)
    return ajustes.authorizationStatus == .authorized || ajustes.authorizationStatus == .provisional
}

Un switch sobre un enum de Apple: @unknown default

authorizationStatus es un enum de Apple, y Apple puede añadirle casos en versiones futuras de iOS. Un default a secas taparía esos casos sin avisar. Para eso existe @unknown default: cubre «lo que Apple añada» y, al compilar con una versión nueva del SDK (las herramientas y librerías de Apple que trae cada versión de Xcode), el compilador te avisa, con una advertencia y no con un fallo, de que hay casos nuevos sin tratar. Con otro enum de Apple, el estado térmico del aparato. Fíjate también en que .nominal, .fair: junta dos casos en una sola rama, separados por una coma:

ejemplo mínimo: @unknown default (con comentarios)
import Foundation
 
// ProcessInfo.ThermalState: cuánto se calienta el aparato (un enum de Apple).
func consejo(_ estado: ProcessInfo.ThermalState) -> String {
    switch estado {                       // un switch devuelve el valor de la rama que toque
    case .nominal, .fair:                 // dos casos que comparten rama, separados por coma
        "Todo en orden"
    case .serious:
        "Baja el ritmo"
    case .critical:
        "Para ya"
    @unknown default:                     // los casos que Apple añada algún día
        "Estado desconocido"
    }
}
 
print(consejo(.fair))
print(consejo(.critical))
Todo en orden
Para ya

Reto · Ejercicio: el texto del botón según el permiso

En tu Playground (reutiliza el mismo y borra lo anterior), escribe una función, textoDelBoton, que reciba el estado del permiso de avisos (un UNAuthorizationStatus, el enum de authorizationStatus, con los casos .notDetermined, .denied, .authorized y .provisional) y devuelva el texto del botón de Marcador:

  • «Activar avisos de gol» si aún no se ha preguntado.
  • «Avisos de gol: activados» si está concedido, también si es provisional.
  • «Avisos desactivados: actívalos en Ajustes» si está denegado.
  • Para los estados que Apple añada en el futuro, «Estado de avisos desconocido», de forma que el compilador te avise cuando aparezca uno nuevo (una rama por defecto a secas no avisa).

Llámala con los estados sin preguntar, denegado y concedido, en ese orden, e imprime cada resultado. Lo que debe salir:

Activar avisos de gol
Avisos desactivados: actívalos en Ajustes
Avisos de gol: activados
Ver solución
solución: el texto del botón según el permiso
import UserNotifications
 
func textoDelBoton(_ estado: UNAuthorizationStatus) -> String {
    switch estado {
    case .notDetermined:
        "Activar avisos de gol"
    case .authorized, .provisional:
        "Avisos de gol: activados"
    case .denied:
        "Avisos desactivados: actívalos en Ajustes"
    @unknown default:
        "Estado de avisos desconocido"
    }
}
 
print(textoDelBoton(.notDetermined))
print(textoDelBoton(.denied))
print(textoDelBoton(.authorized))
Activar avisos de gol
Avisos desactivados: actívalos en Ajustes
Avisos de gol: activados

@unknown default cubre los casos que Apple añada en el futuro y hace que el compilador avise (no falle) al compilar con un SDK nuevo, y case .authorized, .provisional: junta los dos casos que cuentan como concedido. Es lo mismo que acabas de ver con el estado térmico.

Paso 1 · Crea Notificador.swift

Objetivo: un fichero nuevo con el contrato de lo que la app necesita de las notificaciones. Compila, y de momento solo se usará permitido.

MarcadorApp (proyecto)
MarcadorApp
Core
Notifications
Notificador.swiftse crea
En el Project navigator: un fichero nuevo.

Notificador.swift será el único fichero que habla con las notificaciones del sistema. El resto de la app (el ViewModel, en la lección siguiente) solo conocerá un protocolo pequeño, como ya hace con el repositorio.

Avisar es un servicio de toda la app, no de una pantalla. Es la regla de Core (MVVM, estado, repositorio y caso de uso): va en Core lo que no pertenece a ninguna funcionalidad, aunque lo usen varias; si es de una, se queda en ella aunque otras lo lean. Va en Core/Notifications: la subcarpeta se llama por lo que hace. Crea las dos carpetas: clic derecho sobre la carpeta MarcadorApp del código (la que tiene MarcadorApp.swift) → New Folder → Core; clic derecho sobre Core → New Folder → Notifications. Después, clic derecho sobre Core/Notifications → New File from Template… → Swift File → Next. Escribe Notificador, comprueba que en Targets solo está marcado MarcadorApp y pulsa Create. Alternativa desde la Terminal, que crea también las carpetas: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Notifications && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Notifications/Notificador.swift.

Escribe el protocolo. Un protocolo es una lista de lo que un tipo debe saber hacer (Protocolos y extensiones); : AnyObject lo limita a clases:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Core/Notifications/Notificador.swift
import Foundation
import Observation
import UserNotifications
 
// Lo único que el resto de la app necesita saber de las notificaciones.
// El ViewModel habla con este protocolo y no con `UserNotifications`:
// en las pruebas se sustituirá por un espía que anota lo que le piden.
protocol Notificador: AnyObject {
    // ¿Ha dado el usuario permiso?
    var permitido: Bool { get }
    // Avisar de un gol.
    func notificarGol(minuto: Int, jugador: String, equipo: String)
}

Compila con Cmd+B.

Paso 2 · El notificador de verdad: pedir y leer el permiso

Objetivo: una clase NotificadorDeSistema, en el mismo fichero, que sabe preguntar al sistema cómo está el permiso y pedirlo. Aún no avisa de nada: eso es la lección siguiente.

Piezas nuevas:

  • @Observable: la macro que hace que la pantalla se redibuje cuando cambia permitido (@Observable).
  • @ObservationIgnored: lo que no queremos observar, como el centro de notificaciones.
  • private(set) var: se puede leer desde fuera, pero solo cambia desde dentro de la clase.
  • await centro.notificationSettings(): lee el estado del permiso, sin mostrar nada (el ejemplo de arriba).

Una cosa más: la clase hereda de NSObject, la raíz de las clases de Objective-C (el lenguaje anterior de Apple). Hace falta en la lección siguiente, porque el delegado de las notificaciones viene de allí y la exige; mientras, no estorba.

Intenta escribirlo tú, al final de Notificador.swift: NotificadorDeSistema, una clase observable de la que nadie hereda (y que hereda de NSObject), con permitido, que empieza en no, se lee desde fuera y solo cambia dentro; el centro de notificaciones, sin observar; y dos funciones asíncronas. actualizarPermiso pregunta al sistema, sin mostrar nada, y deja permitido en sí si el usuario lo concedió o si es un permiso de prueba. pedirPermiso muestra el diálogo pidiendo banner y sonido, guarda la respuesta y la anota en el registro (Registro, de La app, sus escenas y el paquete Torneo): «permiso de notificaciones: concedido» o «denegado» (y si falla al pedirlo, «no se pudo pedir el permiso de notificaciones:» con el error).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Añade esto al final de Notificador.swift:

Notificador.swift: la clase, sin avisos todavía
// La implementación de verdad, sobre el centro de notificaciones del sistema.
// `NSObject`: la raíz de las clases de Objective-C; el delegado de la lección siguiente la exige.
@Observable
final class NotificadorDeSistema: NSObject {
    // La pantalla lee esto para pintar el botón; solo cambia desde dentro.
    private(set) var permitido = false
 
    // El centro de notificaciones. La pantalla no necesita observarlo.
    @ObservationIgnored private let centro = UNUserNotificationCenter.current()
 
    // Pregunta al sistema cómo está el permiso, sin mostrar nada al usuario.
    func actualizarPermiso() async {
        let ajustes = await centro.notificationSettings()
        permitido = ajustes.authorizationStatus == .authorized || ajustes.authorizationStatus == .provisional
    }
 
    // Muestra el diálogo del sistema (solo la primera vez). Es `async`: la respuesta llega después.
    func pedirPermiso() async {
        do {
            permitido = try await centro.requestAuthorization(options: [.alert, .sound])
        } catch {
            Registro.anotar("no se pudo pedir el permiso de notificaciones: \(error)")
        }
        Registro.anotar("permiso de notificaciones: \(permitido ? "concedido" : "denegado")")
    }
}

Compila con Cmd+B.

Paso 3 · Conéctalo a la app

Objetivo: pulsar «Activar avisos de gol», ver el diálogo del sistema, aceptar y ver cómo el botón cambia. En Android esto es el botón que pedía POST_NOTIFICATIONS en Permisos y notificaciones.

MarcadorApp (proyecto)
MarcadorApp
MarcadorApp.swiftse modifica
ContentView.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
Marcador
UI
PartidoEnDirectoView.swiftse modifica
En el Project navigator: cuatro ficheros cambian.

Info.plist y capabilities: no cambias nada. Las notificaciones locales solo piden la petición en ejecución.

Piezas nuevas de este paso:

  • @Environment(NotificadorDeSistema.self): lee del entorno el objeto que la raíz puso con .environment(notificador), como el repositorio.
  • Un segundo .task en la misma vista: cada .task es una tarea propia, que arranca al aparecer la vista y se cancela al irse (.task frente a .onAppear).
  • Task { await … } dentro de la acción de un botón: la acción no es async, así que una Task abre el sitio donde sí se puede esperar.

Intenta escribirlo tú: la raíz crea el notificador una vez y lo reparte por el entorno; y la pantalla del partido lee el permiso al aparecer y pinta el botón, «Activar avisos de gol» o «Avisos de gol: activados» (desactivado cuando ya está concedido), que pide el permiso al pulsarlo. Las vistas previas de la lista y de ContentView también necesitan el notificador en su entorno, porque el partido lo lee al abrirse. Lo que debes ver: el diálogo del sistema al pulsar el botón y el texto del botón cambiado tras aceptar.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

MarcadorApp.swift: la raíz crea el NotificadorDeSistema una sola vez y lo reparte. Debajo del @State del repositorio:

MarcadorApp.swift: el notificador
    // El notificador se crea UNA vez al arrancar la app y se reparte por el entorno.
    @State private var notificador = NotificadorDeSistema()

y en el body, tras .environment(repositorio):

MarcadorApp.swift: el entorno
                .environment(repositorio)
                .environment(notificador)

PartidoEnDirectoView.swift: junto a las demás propiedades (por ejemplo, debajo de @Environment(\.scenePhase) private var fase), el notificador del entorno, cuyo permiso se lee para pintar el botón:

PartidoEnDirectoView.swift: el notificador
    // El notificador (lo puso la raíz en el entorno) y su permiso, que se lee para pintar el botón.
    @Environment(NotificadorDeSistema.self) private var notificador

Tras el .task(id: duracion) { … }, otra tarea que lee el estado del permiso:

PartidoEnDirectoView.swift: leer el permiso
        .task { await notificador.actualizarPermiso() }

El botón, debajo de «Ver goleadores», dentro del mismo VStack:

PartidoEnDirectoView.swift: el botón
                    // Pedir el permiso de notificaciones. La primera vez sale el diálogo del sistema.
                    Button(notificador.permitido ? "Avisos de gol: activados" : "Activar avisos de gol") {
                        Task { await notificador.pedirPermiso() }
                    }
                    .buttonStyle(.bordered)
                    .disabled(notificador.permitido)

Las dos vistas previas. En ContentView.swift, sustituye el #Preview entero por este:

ContentView.swift: la vista previa
#Preview {
    // La lista y el partido leen el repositorio y el notificador del entorno: aquí se los da la vista previa.
    ContentView()
        .environment(RepositorioDePartidos())
        .environment(NotificadorDeSistema())
}

y en ListaDePartidosView.swift, a la suya le falta solo la última línea:

ListaDePartidosView.swift: la vista previa
#Preview {
    ListaDePartidosView()
        .environment(RepositorioDePartidos())
        .environment(NotificadorDeSistema())   // lo lee el partido al navegar
}

Compila con Cmd+B. Aún no hay ningún aviso de gol: el ViewModel y PartidoContenedor no cambian hasta la lección siguiente.

Probarlo

Ejecuta la app desde Xcode (Cmd+R) en un iPhone del simulador y abre un partido: arranca solo, como en .task frente a .onAppear. Pulsa Activar avisos de gol y responde al diálogo del sistema (el texto depende del idioma del simulador: Allow o Permitir). Con Permitir, el botón pasa a Avisos de gol: activados y queda desactivado. En la consola de Xcode (el panel de abajo; se muestra con Cmd+Shift+Y) verás:

permiso de notificaciones: concedido

Para ver el diálogo otra vez, borra la app del simulador (xcrun simctl uninstall booted com.sebastiannarvaez.marcador): el sistema olvida la respuesta. Con No permitir, el botón ya no abre nada: hay que ir a Ajustes → Notificaciones → Marcador. Si tienes varios simuladores encendidos, booted elige uno de ellos: apaga los demás o pon su identificador en lugar de booted.

Cmd+U lanza las 28 pruebas, como antes: el permiso concedido todavía no cambia nada en ellas.

Punto de control

  • Al abrir un partido, el botón dice «Activar avisos de gol»; al pulsarlo sale el diálogo del sistema y, con Permitir, pasa a «Avisos de gol: activados» y el registro de Xcode dice permiso de notificaciones: concedido.
  • El Info.plist de la app instalada (el plutil -p de más arriba) no tiene ninguna clave de notificaciones ni la de la cámara que probaste.
  • Cmd+U da 28 pruebas en verde: 14 unitarias y 14 de interfaz.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Marcador solo usa notificaciones locales. ¿Qué tiene que añadir al Info.plist para poder pedir el permiso?

  2. 2.El usuario toca «No permitir» en el diálogo de notificaciones. La app vuelve a llamar a requestAuthorization. ¿Qué pasa?

  3. 3.¿Qué es una capability en Xcode?

  4. 4.En Marcador, permitido es un Bool. ¿Qué información no puede distinguir frente a un enum como UNAuthorizationStatus?

  5. 5.Haces un switch sobre un enum de Apple y cierras con @unknown default. ¿Qué ganas frente a un default a secas?

Faltan 5 de 5