Astorya Developers
Integração no app

Embutir no app (WebView)

Exemplos prontos para colar em React Native, Flutter, iOS e Android: peça uma sessão nova, abra a launchUrl e escute os eventos que a Astorya envia para o seu app.

Nesta página
  1. A receita, em qualquer plataforma
  2. Parâmetros da URL
  3. Eventos da ponte
  4. React Native
  5. Flutter
  6. iOS (Swift, WKWebView)
  7. Android (Kotlin, WebView)
  8. Site do parceiro (iframe)
  9. Limitações da versão web
  10. Checklist antes de publicar

A receita, em qualquer plataforma

  1. Peça uma sessão nova ao seu backend

    Sempre logo antes de abrir a tela (a launchUrl vale 60 s e uma vez só). Veja Criar uma sessão.

  2. Acrescente os parâmetros de embed e abra a URL

    embed=1 liga o modo embutido; closable=1 mostra um botão de fechar dentro da Astorya (use se a sua tela não tiver um).

  3. Configure a WebView para áudio

    JavaScript e armazenamento local (DOM storage) ligados, reprodução de mídia sem exigir toque e vídeo/áudio inline.

  4. Escute os eventos da ponte

    Recarregue com uma sessão nova em astorya:session-expired, feche a tela em astorya:close e abra links externos no navegador do sistema.

Parâmetros da URL

ParâmetroEfeito
tokenJá vem na launchUrl. O Astorya Web troca por uma sessão e apaga da barra de endereço.
redirectJá vem na launchUrl quando você envia redirectPath. Não monte à mão.
embed=1Liga o modo embutido: eventos para o app hospedeiro e links externos repassados a você em vez de abertos pela página. Também é ligado sozinho quando a página detecta a ponte (window.ReactNativeWebView) — mas envie sempre, por clareza.
closable=1Mostra um botão de fechar; ao tocar, a Astorya envia astorya:close.

A launchUrl sempre tem query string (?token=…), então basta acrescentar &embed=1&closable=1 — ou usar o construtor de URL da sua plataforma, como nos exemplos.

Eventos da ponte

A Astorya avisa o app hospedeiro com mensagens JSON que têm sempre um campo type. Elas chegam por dois canais, quando existem:

  • WebView nativa: window.ReactNativeWebView.postMessage(string). O valor é uma string JSON — faça JSON.parse. O React Native cria esse objeto sozinho; em Flutter, iOS e Android você o cria com o nome ReactNativeWebView (veja os exemplos).
  • iframe (site do parceiro): window.parent.postMessage(objeto, '*'). O valor já é um objeto. Confira sempre event.origin === 'https://play.astorya.com.br'.
EventoDadosO que o app deve fazer
astorya:ready—A Astorya terminou de carregar. Esconda o seu indicador de carregamento.
astorya:session-expiredreason: "invalid_launch_token" ou "refresh_failed"O link expirou/foi usado ou a sessão acabou. Peça uma nova sessão ao seu backend e carregue a nova launchUrl na mesma WebView.
astorya:close—O membro tocou em fechar (só existe com closable=1). Feche a tela da WebView.
astorya:open-externalurl: stringAbra a URL no navegador do sistema (ou num navegador in-app). No modo embutido a Astorya nunca navega para fora sozinha.
Mensagens (uma por linha)
{ "type": "astorya:ready" }
{ "type": "astorya:session-expired", "reason": "invalid_launch_token" }
{ "type": "astorya:close" }
{ "type": "astorya:open-external", "url": "mailto:suporte@exemplo.com.br" }

Ignore tipos que você não conhece: novos eventos podem ser adicionados sem mudança de versão.

React Native

Instale react-native-webview (npx expo install react-native-webview no Expo). O window.ReactNativeWebView já existe; basta usar onMessage.

AstoryaScreen.jsx
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Linking, StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';
import { api } from './api'; // cliente do SEU backend

const PLAY_BASE_URL = 'https://play.astorya.com.br';

async function fetchLaunchUrl() {
  // Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
  const { launchUrl } = await api.post('/astorya/session');
  return `${launchUrl}&embed=1&closable=1`;
}

export function AstoryaScreen({ navigation }) {
  const [uri, setUri] = useState(null);
  const [ready, setReady] = useState(false);

  const openSession = useCallback(async () => {
    setReady(false);
    setUri(await fetchLaunchUrl());
  }, []);

  useEffect(() => {
    openSession();
  }, [openSession]);

  const onMessage = useCallback(
    (event) => {
      let message;
      try {
        message = JSON.parse(event.nativeEvent.data);
      } catch {
        return;
      }
      switch (message.type) {
        case 'astorya:ready':
          setReady(true);
          break;
        case 'astorya:session-expired':
          openSession();
          break;
        case 'astorya:close':
          navigation.goBack();
          break;
        case 'astorya:open-external':
          Linking.openURL(message.url);
          break;
        default:
          break;
      }
    },
    [navigation, openSession],
  );

  return (
    <View style={styles.root}>
      {uri && (
        <WebView
          source={{ uri }}
          onMessage={onMessage}
          javaScriptEnabled
          domStorageEnabled
          allowsInlineMediaPlayback
          mediaPlaybackRequiresUserAction={false}
          setSupportMultipleWindows={false}
          onShouldStartLoadWithRequest={(request) => {
            if (request.url.startsWith(PLAY_BASE_URL)) return true;
            Linking.openURL(request.url);
            return false;
          }}
        />
      )}
      {!ready && (
        <View style={styles.loader} pointerEvents="none">
          <ActivityIndicator size="large" color="#7747F3" />
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#fff' },
  loader: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
});

Flutter

Dependências: webview_flutter (4.x), webview_flutter_android, webview_flutter_wkwebview e url_launcher. O canal JavaScript chamado ReactNativeWebView cria exatamente a ponte que a Astorya procura.

astorya_page.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';

import 'api.dart'; // cliente do SEU backend

const playBaseUrl = 'https://play.astorya.com.br';

class AstoryaPage extends StatefulWidget {
  const AstoryaPage({super.key});

  @override
  State<AstoryaPage> createState() => _AstoryaPageState();
}

class _AstoryaPageState extends State<AstoryaPage> {
  late final WebViewController _controller;
  bool _ready = false;

  @override
  void initState() {
    super.initState();

    final PlatformWebViewControllerCreationParams params;
    if (WebViewPlatform.instance is WebKitWebViewPlatform) {
      params = WebKitWebViewControllerCreationParams(
        allowsInlineMediaPlayback: true,
        mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
      );
    } else {
      params = const PlatformWebViewControllerCreationParams();
    }

    _controller = WebViewController.fromPlatformCreationParams(params)
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel('ReactNativeWebView', onMessageReceived: _onMessage)
      ..setNavigationDelegate(NavigationDelegate(
        onNavigationRequest: (request) {
          if (request.url.startsWith(playBaseUrl)) {
            return NavigationDecision.navigate;
          }
          launchUrl(Uri.parse(request.url), mode: LaunchMode.externalApplication);
          return NavigationDecision.prevent;
        },
      ));

    final platform = _controller.platform;
    if (platform is AndroidWebViewController) {
      platform.setMediaPlaybackRequiresUserGesture(false);
    }

    _openSession();
  }

  Future<void> _openSession() async {
    if (_ready) setState(() => _ready = false);
    // Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
    final String sessionUrl = await Api.createAstoryaSession();
    if (!mounted) return;
    final uri = Uri.parse(sessionUrl);
    await _controller.loadRequest(uri.replace(queryParameters: {
      ...uri.queryParameters,
      'embed': '1',
      'closable': '1',
    }));
  }

  void _onMessage(JavaScriptMessage message) {
    final Map<String, dynamic> event;
    try {
      event = jsonDecode(message.message) as Map<String, dynamic>;
    } catch (_) {
      return;
    }
    switch (event['type']) {
      case 'astorya:ready':
        setState(() => _ready = true);
      case 'astorya:session-expired':
        _openSession();
      case 'astorya:close':
        Navigator.of(context).pop();
      case 'astorya:open-external':
        launchUrl(Uri.parse(event['url'] as String), mode: LaunchMode.externalApplication);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            WebViewWidget(controller: _controller),
            if (!_ready) const Center(child: CircularProgressIndicator()),
          ],
        ),
      ),
    );
  }
}

iOS (Swift, WKWebView)

O WKUserScript cria window.ReactNativeWebView antes da página carregar e repassa as mensagens para o handler nativo astorya. O WeakMessageHandler evita o ciclo de referência clássico do WKUserContentController.

AstoryaViewController.swift
import UIKit
import WebKit

final class AstoryaViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
    private static let playBaseUrl = "https://play.astorya.com.br"
    private var webView: WKWebView!
    private let loader = UIActivityIndicatorView(style: .large)

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        // Ponte: a Astorya chama window.ReactNativeWebView.postMessage(stringJSON).
        let bridge = WKUserScript(
            source: """
            window.ReactNativeWebView = {
              postMessage: function (data) { window.webkit.messageHandlers.astorya.postMessage(data); }
            };
            """,
            injectionTime: .atDocumentStart,
            forMainFrameOnly: true
        )
        let contentController = WKUserContentController()
        contentController.addUserScript(bridge)
        contentController.add(WeakMessageHandler(self), name: "astorya")

        let config = WKWebViewConfiguration()
        config.userContentController = contentController
        config.websiteDataStore = .default()
        config.allowsInlineMediaPlayback = true
        config.mediaTypesRequiringUserActionForPlayback = []

        webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = self
        webView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(webView)
        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])

        loader.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(loader)
        loader.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        loader.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true

        openSession()
    }

    private func openSession() {
        loader.startAnimating()
        Task {
            do {
                // Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
                let launchUrl = try await PartnerAPI.createAstoryaSession()
                guard var components = URLComponents(string: launchUrl) else { return }
                components.queryItems = (components.queryItems ?? []) + [
                    URLQueryItem(name: "embed", value: "1"),
                    URLQueryItem(name: "closable", value: "1"),
                ]
                if let url = components.url {
                    webView.load(URLRequest(url: url))
                }
            } catch {
                loader.stopAnimating()
                // Mostre uma mensagem de erro com um botão "Tentar de novo".
            }
        }
    }

    // MARK: WKScriptMessageHandler

    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        guard let text = message.body as? String,
              let data = text.data(using: .utf8),
              let event = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
              let type = event["type"] as? String else { return }

        switch type {
        case "astorya:ready":
            loader.stopAnimating()
        case "astorya:session-expired":
            openSession()
        case "astorya:close":
            dismiss(animated: true)
        case "astorya:open-external":
            if let value = event["url"] as? String, let url = URL(string: value) {
                UIApplication.shared.open(url)
            }
        default:
            break
        }
    }

    // MARK: WKNavigationDelegate — links de fora da Astorya vão para o sistema.

    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }
        if url.absoluteString.hasPrefix(Self.playBaseUrl) || url.scheme == "about" || url.scheme == "blob" {
            decisionHandler(.allow)
        } else {
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
        }
    }
}

/// Evita que o WKUserContentController segure o view controller para sempre.
private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
    private weak var target: WKScriptMessageHandler?

    init(_ target: WKScriptMessageHandler) {
        self.target = target
    }

    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        target?.userContentController(userContentController, didReceive: message)
    }
}

Android (Kotlin, WebView)

O addJavascriptInterface com o nome ReactNativeWebView expõe um postMessage(String) que a Astorya chama diretamente. Ele roda fora da thread principal, por isso o runOnUiThread.

AstoryaActivity.kt
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject

class AstoryaActivity : AppCompatActivity() {

    private lateinit var webView: WebView

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        webView = WebView(this)
        setContentView(webView)

        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            mediaPlaybackRequiresUserGesture = false
        }

        // Ponte: a Astorya chama window.ReactNativeWebView.postMessage(stringJSON).
        webView.addJavascriptInterface(AstoryaBridge(), "ReactNativeWebView")

        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                if (request.url.toString().startsWith(PLAY_BASE_URL)) return false
                startActivity(Intent(Intent.ACTION_VIEW, request.url))
                return true
            }
        }

        onBackPressedDispatcher.addCallback(this) {
            if (webView.canGoBack()) webView.goBack() else finish()
        }

        openSession()
    }

    private fun openSession() {
        lifecycleScope.launch {
            // Sua rota autenticada, que chama POST /partner/v1/sessions no servidor.
            val launchUrl = PartnerApi.createAstoryaSession()
            val url = Uri.parse(launchUrl).buildUpon()
                .appendQueryParameter("embed", "1")
                .appendQueryParameter("closable", "1")
                .build()
                .toString()
            webView.loadUrl(url)
        }
    }

    private fun handleEvent(event: JSONObject) {
        when (event.optString("type")) {
            "astorya:ready" -> { /* esconda o seu loader */ }
            "astorya:session-expired" -> openSession()
            "astorya:close" -> finish()
            "astorya:open-external" ->
                startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.getString("url"))))
        }
    }

    private inner class AstoryaBridge {
        @JavascriptInterface
        fun postMessage(message: String) {
            val event = try {
                JSONObject(message)
            } catch (e: JSONException) {
                return
            }
            runOnUiThread { handleEvent(event) }
        }
    }

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }

    companion object {
        private const val PLAY_BASE_URL = "https://play.astorya.com.br"
    }
}

Site do parceiro (iframe)

Também dá para embutir num site. A sessão é pedida do mesmo jeito (pelo seu backend); os eventos chegam por postMessage como objetos.

Página hospedeira
const PLAY_BASE_URL = 'https://play.astorya.com.br';
const frame = document.querySelector('#astorya'); // <iframe id="astorya" allow="autoplay; fullscreen">

async function openAstorya() {
  const res = await fetch('/astorya/session', { method: 'POST', credentials: 'include' }); // SUA rota
  const { launchUrl } = await res.json();
  frame.src = `${launchUrl}&embed=1&closable=1`;
}

window.addEventListener('message', (event) => {
  if (event.origin !== PLAY_BASE_URL) return;
  const message = event.data; // objeto, não string
  if (message.type === 'astorya:session-expired') openAstorya();
  if (message.type === 'astorya:close') frame.hidden = true;
  if (message.type === 'astorya:open-external') window.open(message.url, '_blank', 'noopener');
});

openAstorya();

Limitações da versão web

O que a WebView não faz tão bem quanto o app nativo da Astorya:

  • Áudio em segundo plano e na tela bloqueada é limitado, principalmente no iOS: o sistema pode pausar a reprodução quando o app vai para segundo plano ou a tela apaga, mesmo com UIBackgroundModes = audio. No Android, o comportamento varia por fabricante e economia de bateria.
  • Sem downloads offline. A versão web precisa de internet para tocar e ler.
  • Os controles de mídia da tela de bloqueio e do fone podem não aparecer ou não responder.

Para membros que ouvem muito (no treino, no carro, sem sinal), recomendamos um botão “Abrir no app Astorya” ao lado da WebView, levando à loja do app. A forma de acesso desses membros ao app nativo é combinada com a equipe Astorya na implantação.

Checklist antes de publicar

  • A sessão é pedida a cada abertura da tela, nunca reaproveitada.
  • astorya:session-expired recarrega com uma sessão nova (teste abrindo a mesma launchUrl duas vezes).
  • Links externos saem para o navegador do sistema; a WebView só navega em https://play.astorya.com.br.
  • O áudio começa a tocar sem toque extra no iOS e no Android.
  • Nenhum segredo da Astorya está no código do app.