Saltar al contenido
Development

Un botón de suscripción, una API de MusicKit que nunca había usado, y una sheet que se cierra en silencio

Por Victor Da Luz
swiftmusickitiosdev-logdeep-cut-atlas

Deep Cut Atlas recibió un rechazo de App Review por errores crudos de MusicKit apareciendo en pantalla cuando alguien está autorizado pero no tiene en realidad una suscripción a Apple Music. Ya arreglé la parte cercana al crash de eso: una vista de bloqueo real en vez de un stack trace. Pero el bloqueo en sí era tosco. Tanto el estado “hace falta una suscripción” como el estado “ya hay una suscripción, solo falta aceptar los términos una vez” mostraban exactamente el mismo título, “Apple Music Required”, lo cual es sencillamente incorrecto para el segundo caso. Y la única acción del estado sin suscripción era un enlace de texto plano que decía “Try Again.” ¿Intentar qué de nuevo? No había ninguna forma de arreglar el problema de verdad desde dentro de la app.

Este fue el seguimiento: retitular el estado confuso, y darle al estado sin suscripción un botón de suscripción real.

Leer el framework en vez de adivinar

MusicKit trae un modificador de SwiftUI llamado musicSubscriptionOffer(isPresented:options:onLoadCompletion:) que presenta la propia sheet nativa de Apple para “suscribirse a Apple Music”, sin necesitar interfaz personalizada. Nunca lo había usado, así que en vez de adivinar la firma a partir de documentación medio recordada, la leí directo del framework:

find /Applications/Xcode.app/.../iPhoneSimulator.sdk -iname "*MusicKit*"

lo cual sacó a la luz todo un segundo framework que no sabía que existía: _MusicKit_SwiftUI.framework. Su archivo .swiftinterface tenía la firma real, más un struct Options con campos affiliateToken/campaignToken que decidí no tocar ya que nada en esta base de código configuró jamás ninguno. Grepear la propia interfaz compilada de un framework le gana a confiar en mi memoria de una API que había visto mencionada dos veces.

El error que se habría publicado si no hubiera mirado dos veces

Acá está la parte que realmente importó. musicSubscriptionOffer presenta una sheet dentro de la app. No manda la app a segundo plano ni dispara ninguna transición de scene-phase. Mientras tanto, la verificación de estado de suscripción de esta app solo se vuelve a correr al iniciar la app o al volver desde segundo plano. Juntando esos dos hechos: alguien toca “Get Apple Music,” se suscribe en la sheet nativa, la cierra… y ve exactamente la misma pantalla “Apple Music Required”, porque nada le dijo a la app que verificara de nuevo. El arreglo funciona perfecto para alguien que manda la app a segundo plano y la vuelve a abrir, y es un callejón sin salida para quien no lo hace.

Eso es una ruta de conversión rota, no un vacío cosmético. Lo detecté antes de escribir una sola línea del arreglo, recorriendo mentalmente qué pasa cuando la sheet se cierra: nada con forma de scenePhase iba a dispararse jamás, así que algo más tenía que hacerlo.

.onChange(of: showingSubscriptionOffer) { wasPresented, isPresented in
    if wasPresented && !isPresented {
        Task { await onRetry() }
    }
}

Vuelve a verificar en el instante en que la sheet se cierra, esté suscrito o no. Barato, y significa que la promesa del botón (“consigue Apple Music, vuelve suscrito”) es de verdad cierta.

Una verificación en vivo que casi no pude correr

musicSubscriptionOffer habla con MusicKit real, lo cual significa que está muerto en el simulador, igual que todo lo demás de MusicKit en esta app. Así que la única forma de confirmar de verdad que el botón hacía lo que decía era una pasada en un dispositivo real, tocando el botón de verdad y observando la sheet real de Apple. En el iPhone 13 de repuesto para pruebas, la sheet apareció y dijo “You are already an Apple Music subscriber”, que es exactamente lo que debería ver una cuenta con suscripción real, y confirmó que la llamada estaba llegando al servicio de suscripción en vivo de Apple en vez de a algún mock local. Cerrarla disparó el refresh limpiamente, sin crash, sin bloqueo.

No puedo probar de punta a punta lo que ve una cuenta genuinamente sin suscripción sin tener una, lo cual es un vacío conocido que viene arrastrado desde el arreglo original del rechazo. Pero “¿el botón presenta lo real y se comporta bien al cerrarse?” ahora es un sí confirmado, no una suposición.

Un hallazgo menor: fijar el idioma del simulador sin tocar Settings

Verificar los nuevos strings en español significaba cambiar el simulador a es-419, y ya había documentado antes que las anulaciones de idioma por app no funcionan en esta combinación de Xcode/iOS, solo funcionaba el flujo completo de la interfaz de la app Settings. Resulta que hay una ruta más rápida por CLI que no había probado: escribir el dominio de preferencias global (no uno por app) y reiniciar el simulador:

xcrun simctl spawn <udid> defaults write -g AppleLanguages -array "es-419" "en"
xcrun simctl spawn <udid> defaults write -g AppleLocale -string "es_419"
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>

Funcionó al primer intento. Cosa chica, pero convierte un paso de “navegar tres pantallas de Settings y esperar un respring” en dos comandos de shell.

Reflexión

La parte interesante de este issue no fue el botón, fue notar que una acción de interfaz correctamente codificada igual puede ser un callejón sin salida funcional si nada más adelante reacciona a ella. “La sheet se presenta” y “la app sabe que la sheet se cerró” son dos afirmaciones separadas, y verificar solo la primera habría publicado un botón de suscripción que en silencio no funciona para la mitad de sus usuarios.

Lecturas relacionadas