Dos tipos de actualización in-app

Play Core ofrece dos flujos para actualizar la app sin que el usuario tenga que ir al Play Store manualmente:

  • Flexible update: la actualización se descarga en segundo plano mientras el usuario sigue usando la app. Cuando termina, se muestra un banner o botón para instalar. El usuario puede ignorarlo y seguir usando la versión vieja.
  • Immediate update: bloquea la app con una pantalla de Play Store hasta que la actualización se instala. El usuario no puede continuar sin actualizar.
# Cuándo usar cada uno:

# Flexible — para la mayoría de las actualizaciones
# → Mejoras de UX, nuevas features, correcciones menores
# → El usuario puede ignorarla sin consecuencias inmediatas
# → No interrumpe el flujo de uso

# Immediate — solo para actualizaciones críticas
# → Correcciones de seguridad críticas
# → Cambios de API del backend incompatibles con la versión vieja
# → Cuando la app deja de funcionar sin la actualización
# → Usar con criterio — interrumpir al usuario tiene un costo real

Setup de In-App Updates

// build.gradle (app)
dependencies {
    implementation("com.google.android.play:app-update:2.1.0")
    implementation("com.google.android.play:app-update-ktx:2.1.0")  // extensiones KTX
}

Flujo flexible — el más común

class MainActivity : AppCompatActivity() {

    private val appUpdateManager by lazy { AppUpdateManagerFactory.create(this) }

    // Listener para el estado de la descarga
    private val installStateListener = InstallStateUpdatedListener { state ->
        when (state.installStatus()) {
            InstallStatus.DOWNLOADED -> {
                // La actualización se descargó — mostrar snackbar para instalar
                mostrarSnackbarActualizacion()
            }
            InstallStatus.FAILED -> {
                // La descarga falló — podés reintentar o ignorar
            }
            InstallStatus.CANCELED -> {
                // El usuario canceló
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        verificarActualizacion()
    }

    private fun verificarActualizacion() {
        appUpdateManager.appUpdateInfo
            .addOnSuccessListener { info ->
                when {
                    // Hay una actualización disponible y no está en progreso
                    info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
                        && info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> {

                        // Registrar el listener antes de iniciar
                        appUpdateManager.registerListener(installStateListener)

                        // Iniciar el flujo flexible
                        appUpdateManager.startUpdateFlowForResult(
                            info,
                            AppUpdateType.FLEXIBLE,
                            this,
                            REQUEST_CODE_UPDATE  // cualquier int distinto de 0
                        )
                    }

                    // Hay una descarga completada que todavía no se instaló
                    // (ej: el usuario abrió la app después de descargar en background)
                    info.installStatus() == InstallStatus.DOWNLOADED -> {
                        mostrarSnackbarActualizacion()
                    }
                }
            }
    }

    private fun mostrarSnackbarActualizacion() {
        Snackbar.make(
            binding.root,
            "Actualización lista para instalar",
            Snackbar.LENGTH_INDEFINITE
        ).apply {
            setAction("Instalar") {
                appUpdateManager.completeUpdate()
                // completeUpdate() reinicia la app para aplicar la actualización
            }
            show()
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_CODE_UPDATE) {
            when (resultCode) {
                RESULT_OK -> { /* El usuario aceptó — la descarga está en progreso */ }
                RESULT_CANCELED -> {
                    // El usuario rechazó o cerró el diálogo
                    // No forzar — respetar la decisión
                }
                ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> {
                    // Error al iniciar el flujo — reintentar en la próxima sesión
                }
            }
        }
    }

    override fun onResume() {
        super.onResume()
        // Verificar si hay una actualización descargada y no instalada
        // (el usuario puede haber dejado la app en background mientras descargaba)
        appUpdateManager.appUpdateInfo
            .addOnSuccessListener { info ->
                if (info.installStatus() == InstallStatus.DOWNLOADED) {
                    mostrarSnackbarActualizacion()
                }
            }
    }

    override fun onDestroy() {
        super.onDestroy()
        appUpdateManager.unregisterListener(installStateListener)
    }

    companion object {
        private const val REQUEST_CODE_UPDATE = 100
    }
}

Flujo inmediato

private fun verificarActualizacionCritica() {
    appUpdateManager.appUpdateInfo
        .addOnSuccessListener { info ->
            if (info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
                && info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) {

                appUpdateManager.startUpdateFlowForResult(
                    info,
                    AppUpdateType.IMMEDIATE,
                    this,
                    REQUEST_CODE_UPDATE
                )
            }
        }
}

override fun onResume() {
    super.onResume()
    // Para el flujo IMMEDIATE: si el usuario mandó la app al background
    // durante la actualización (presionando Home), verificar al volver
    appUpdateManager.appUpdateInfo
        .addOnSuccessListener { info ->
            if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
                // La actualización estaba en progreso — reanudarla
                appUpdateManager.startUpdateFlowForResult(
                    info,
                    AppUpdateType.IMMEDIATE,
                    this,
                    REQUEST_CODE_UPDATE
                )
            }
        }
}

// Con flujo IMMEDIATE, el usuario no puede cancelar desde la app
// Solo puede ir a Settings y desinstalar
// Usarlo solo cuando es verdaderamente crítico

Testear In-App Updates sin publicar

// En desarrollo, la API retorna UNKNOWN porque no hay info de Play Store
// Para testear, usar FakeAppUpdateManager:

// En el módulo de DI o en la Application:
val appUpdateManager: AppUpdateManager = if (BuildConfig.DEBUG) {
    FakeAppUpdateManager(context).apply {
        // Simular que hay una actualización disponible
        setUpdateAvailable(2)  // versionCode de la "nueva versión"
        // Simular que la descarga se completó:
        // setInstallStatus(InstallStatus.DOWNLOADED)
    }
} else {
    AppUpdateManagerFactory.create(context)
}

// FakeAppUpdateManager permite simular todos los estados:
// .setUpdateAvailable(versionCode) → hay actualización disponible
// .setUpdateNotAvailable() → no hay actualización
// .setInstallStatus(InstallStatus.DOWNLOADING) → descargando
// .setInstallStatus(InstallStatus.DOWNLOADED) → descarga completa
// .userAcceptsUpdate() → el usuario aceptó (para tests automáticos)
// .userRejectsUpdate() → el usuario rechazó

In-App Reviews — cuándo pedirlas

Google tiene políticas estrictas sobre cuándo y cómo pedir reseñas. Violarlas puede resultar en la eliminación de la app del Play Store. Las reglas más importantes:

# Lo que Google prohíbe explícitamente:
# ✗ Pedir la reseña después de una acción positiva específica ("¿te gustó? Califícanos")
# ✗ Pedir la reseña a cambio de algo (monedas, puntos, contenido desbloqueado)
# ✗ Interceptar o modificar el flujo del diálogo de reseña
# ✗ Pedir múltiples veces en la misma sesión
# ✗ Pedir en el onboarding o al primer arranque

# Lo que SÍ está bien:
# ✓ Después de que el usuario completó un flujo completo (finalizó un nivel,
#   completó una compra, usó la app durante X sesiones)
# ✓ En un momento de baja fricción — no cuando está en medio de una tarea
# ✓ Con un límite de frecuencia razonable (Play Core lo maneja automáticamente,
#   pero no dependas solo de eso — también controlalo desde tu lado)

# El momento ideal:
# → Después de 3-5 sesiones de uso exitoso
# → Después de completar una acción que representa valor para el usuario
# → Nunca después de un error o crash (obvio, pero vale mencionarlo)

Play Core limita la frecuencia automáticamente — pero no confíes solo en esoGoogle aplica un límite de cuántas veces puede mostrarse el diálogo de reseña por usuario por app. Si tu app pide la reseña demasiado seguido, Play Core simplemente no muestra el diálogo sin avisar. Guardá vos también cuándo fue la última vez que la pediste para no llamar a la API innecesariamente.

Setup de In-App Reviews

// build.gradle (app)
dependencies {
    implementation("com.google.android.play:review:2.0.1")
    implementation("com.google.android.play:review-ktx:2.0.1")
}

Solicitar la reseña — el flujo completo

class ReseñaManager(private val context: Context) {

    private val reviewManager = ReviewManagerFactory.create(context)

    // Llamar cuando el momento es apropiado
    fun solicitarReseña(activity: Activity) {
        // Paso 1: obtener un ReviewInfo (requiere conexión con Play Store)
        // Hacer esto con anticipación si es posible, no en el momento de mostrar
        reviewManager.requestReviewFlow()
            .addOnCompleteListener { task ->
                if (task.isSuccessful) {
                    val reviewInfo = task.result
                    // Paso 2: lanzar el flujo de reseña
                    lanzarFlujo(activity, reviewInfo)
                }
                // Si falla: Play Core no pudo obtener el ReviewInfo
                // Puede ser por falta de conexión o límite de frecuencia alcanzado
                // En ambos casos: no hacer nada, continuar normalmente
                // NUNCA mostrar tu propio diálogo alternativo pidiendo la reseña
            }
    }

    private fun lanzarFlujo(activity: Activity, reviewInfo: ReviewInfo) {
        reviewManager.launchReviewFlow(activity, reviewInfo)
            .addOnCompleteListener {
                // El flujo terminó — INDEPENDIENTEMENTE de si el usuario dejó reseña o no
                // Google intencionalmente no te dice si el usuario calificó
                // Esto es para evitar que las apps traten diferente a quienes califican
                // vs quienes no califican
                // → Continuar el flujo normal de la app
            }
    }
}

// En el ViewModel — controlar cuándo pedir la reseña:
class MainViewModel @Inject constructor(
    private val prefs: AppPreferences,
    private val reseñaManager: ReseñaManager
) : ViewModel() {

    fun onSesionCompletada(activity: Activity) {
        val sesionesCompletadas = prefs.getSesionesCompletadas()
        prefs.incrementarSesiones()

        // Pedir en la sesión 5, 20 y 50 (con al menos 30 días entre pedidos)
        val debePedir = sesionesCompletadas in listOf(5, 20, 50)
            && diasDesdUltimoPedido() >= 30

        if (debePedir) {
            prefs.guardarFechaUltimoPedidoReseña(System.currentTimeMillis())
            reseñaManager.solicitarReseña(activity)
        }
    }

    private fun diasDesdUltimoPedido(): Int {
        val ultimo = prefs.getFechaUltimoPedidoReseña()
        if (ultimo == 0L) return Int.MAX_VALUE  // nunca se pidió → siempre cumple
        val diff = System.currentTimeMillis() - ultimo
        return (diff / (1000 * 60 * 60 * 24)).toInt()
    }
}

No sabés si el usuario dejó una reseña — y es intencionalEl callback de launchReviewFlow no te dice si el usuario calificó, qué estrellas dio, ni si cerró el diálogo sin hacer nada. Google diseñó la API así adrede para evitar que las apps discriminen a los usuarios según su calificación (ej: dar beneficios a quienes dan 5 estrellas). El único mensaje que podés dar al terminar es "gracias por usar la app".

Testear In-App Reviews sin publicar

// En producción, el diálogo solo aparece si la app está publicada en Play Store
// y el dispositivo tiene la cuenta que descargó la app
// Para testing en desarrollo: FakeReviewManager

val reviewManager: ReviewManager = if (BuildConfig.DEBUG) {
    FakeReviewManager(context)
    // FakeReviewManager muestra un diálogo de prueba inmediatamente
    // No requiere que la app esté publicada ni conectada a Play Store
} else {
    ReviewManagerFactory.create(context)
}

// Con FakeReviewManager:
// → requestReviewFlow() retorna un ReviewInfo de prueba inmediatamente
// → launchReviewFlow() muestra un diálogo de prueba visible
// → El diálogo dice "This is a test review dialog" — no es el diálogo real

// Para testear en un dispositivo con la cuenta real de Play Store:
// 1. Subir la app al track interno
// 2. Instalar desde Play Store (no desde Android Studio)
// 3. Solo entonces el diálogo real aparece
// Nota: Play Core puede no mostrar el diálogo real incluso así
// si el usuario ya calificó la app o si el límite de frecuencia se alcanzó

Los errores más comunes

# Error 1: Pedir la reseña al inicio de la app o en el onboarding
# → El usuario no tuvo tiempo de formar una opinión
# → Google lo prohíbe explícitamente
# → La solución: esperar al menos 3-5 sesiones de uso real

# Error 2: Mostrar un diálogo propio antes del diálogo de Play Core
# "¿Estás disfrutando la app? → Sí → [mostrar diálogo Play Core]"
# →  Viola las políticas de Google (interceptar el flujo de reseña)
# → Google puede remover la app del Play Store por esto

# Error 3: No manejar el caso donde requestReviewFlow() falla
// ❌ MAL:
reviewManager.requestReviewFlow().addOnSuccessListener { info ->
    reviewManager.launchReviewFlow(activity, info)
}
// Si falla, no pasa nada — pero tampoco maneja el error

// ✓ BIEN: usar addOnCompleteListener en lugar de addOnSuccessListener
reviewManager.requestReviewFlow().addOnCompleteListener { task ->
    if (task.isSuccessful) {
        reviewManager.launchReviewFlow(activity, task.result)
    }
    // Si falla: continuar normalmente, sin alternativas
}

# Error 4: Llamar launchReviewFlow en un contexto no-Activity
# → Requiere una Activity activa — no funciona desde un Service,
#   Fragment.requireContext() solo a veces funciona
# → Pasar la Activity explícitamente o usar el resultado del ActivityResult API

# Error 5: Para In-App Updates — no verificar en onResume
# → Si el usuario mandó la app al background durante el flujo IMMEDIATE,
#   al volver hay que retomarlo desde onResume
# → Sin esto, la app queda en un estado inconsistente