# Native apps

Show a form in an iOS, Android, React Native or Flutter app through a web view, and close it when the form is answered.

An iOS, Android, React Native or Flutter app shows a form by opening a page OneStar makes for apps in a web view. The page shows the form alone at the width of the view, in the form's colors, and tells your app when the person has finished so you can close it. There is no SDK to add.

## The page to open

```text URL
https://onestar.sh/f/<slug>/embed
```

`<slug>` is the form's link name. The page exists only when the form is shared, so open the form's **Share** tab and share it by link or publicly first. On the **Install** tab, pick **iOS and Android** to copy the address with your slug in it.

The page runs on OneStar's own site, so you do not add anything under **Allowed sites** for it. It is never indexed by search engines. When the form is closed or paused, it shows the form's closed message instead.

## Tell OneStar about the app

Add these to the query string, so Results can split answers by them:

| Parameter | Example | What it is |
|---|---|---|
| `app_version` | `4.2.0` | Your app's version. |
| `platform` | `ios` | Your word for the platform, such as `ios`, `android` or `ipados`. |
| `placement` | `settings` | A name for the screen the form opens from. Without one, answers count under `app`. |

```text URL
https://onestar.sh/f/<slug>/embed?app_version=4.2.0&platform=ios&placement=settings
```

Each value is up to 40 characters of letters, digits, spaces, dots, dashes and underscores, starting with a letter or digit. A value with anything else (`4.2.0 (123)`, an email) is dropped, not refused. A placement is stored in lower case with dashes for spaces, and it cannot hold a dot. See [Context and placements](https://onestar.sh/docs/feedback/context-and-placements.md) for what Results does with them.

## Signed in users

A form set to **Signed in users of my product** (Pro and Max) needs a token your server signs for the person (see [Signed in users](https://onestar.sh/docs/feedback/identify-users.md)). Pass it after `#`, never in the query:

```text URL
https://onestar.sh/f/<slug>/embed?platform=ios#token=<signed token>
```

The part after `#` never leaves the device in a request, so the token stays out of server logs. The page reads it once, then removes it from its own address so it cannot end up in a screenshot or a shared link. Opened without a token, a form for signed in users says it can only be answered from your app.

Sign the token on your server and have the app fetch a fresh one when it opens the form. Never put the signing secret in the app itself: anything in an app binary can be read out of it.

## Knowing when the form is answered

When the person finishes the form (the last step is sent, not just the first tap), the page posts this message to your app:

```json message
{ "type": "onestar:submitted", "form": "f_hemsz66yupbd76d2" }
```

It says nothing about the answer. The page posts it to whichever of these bridges your app installed:

| Platform | What the page calls | What you receive |
|---|---|---|
| iOS (WKWebView) | `window.webkit.messageHandlers.onestar.postMessage` | A dictionary |
| Android (WebView) | `window.OneStarAndroid.onMessage` | A JSON string |
| React Native | `window.ReactNativeWebView.postMessage` | A JSON string |

The usual thing to do with it is close the web view, perhaps after a short pause so the person sees the thank you.

## iOS

Add a script message handler named `onestar` before loading the page:

```swift FeedbackViewController.swift
import UIKit
import WebKit

final class FeedbackViewController: UIViewController, WKScriptMessageHandler {
    private var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        let config = WKWebViewConfiguration()
        config.userContentController.add(self, name: "onestar")
        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)

        var url = URLComponents(string: "https://onestar.sh/f/your-form-slug/embed")!
        url.queryItems = [
            URLQueryItem(name: "app_version", value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String),
            URLQueryItem(name: "platform", value: "ios"),
            URLQueryItem(name: "placement", value: "settings"),
        ]
        // For a signed in user: url.fragment = "token=\(tokenFromYourServer)"
        webView.load(URLRequest(url: url.url!))
    }

    func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
        guard let body = message.body as? [String: Any], body["type"] as? String == "onestar:submitted" else { return }
        dismiss(animated: true)
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        webView.configuration.userContentController.removeScriptMessageHandler(forName: "onestar")
    }
}
```

The content controller holds its handler strongly, so remove the handler when the view goes away, or the view controller is never freed.

## Android

Turn JavaScript on and add an object named `OneStarAndroid` with an `onMessage(String)` method:

```kotlin FeedbackActivity.kt
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import org.json.JSONObject

class FeedbackActivity : AppCompatActivity() {
    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val webView = WebView(this)
        setContentView(webView)
        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true
        webView.addJavascriptInterface(Bridge(), "OneStarAndroid")

        val url = Uri.parse("https://onestar.sh/f/your-form-slug/embed").buildUpon()
            .appendQueryParameter("app_version", BuildConfig.VERSION_NAME)
            .appendQueryParameter("platform", "android")
            .appendQueryParameter("placement", "settings")
            // For a signed in user: .encodedFragment("token=$tokenFromYourServer")
            .build()
        webView.loadUrl(url.toString())
    }

    inner class Bridge {
        @JavascriptInterface
        fun onMessage(json: String) {
            if (JSONObject(json).optString("type") == "onestar:submitted") {
                runOnUiThread { finish() }
            }
        }
    }
}
```

Methods marked `@JavascriptInterface` run on a background thread, so move UI work to the main thread as above. Add the interface before `loadUrl`. `domStorageEnabled` lets the form remember on the device that it was answered; without it the form still works, but forgets when the view closes.

## React Native

With `react-native-webview`, giving the `WebView` an `onMessage` prop is all it takes. The message arrives as a string:

```tsx FeedbackScreen.tsx
import { WebView } from "react-native-webview";

const FORM = "https://onestar.sh/f/your-form-slug/embed?platform=ios&placement=settings";

export function FeedbackScreen({ onDone }: { onDone: () => void }) {
  return (
    <WebView
      source={{ uri: FORM }}
      onMessage={(e) => {
        const msg = JSON.parse(e.nativeEvent.data);
        if (msg.type === "onestar:submitted") onDone();
      }}
    />
  );
}
```

Build the address with `Platform.OS` and your app's version in place of the fixed values.

## Flutter

The page calls only the three bridges above, and a `webview_flutter` JavaScript channel matches none of them: a channel is an object with a `postMessage` method, while the Android bridge expects `onMessage`. So add a channel of your own and, once the page has loaded, have it listen for the event the form fires when an answer is sent:

```dart feedback_page.dart
import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

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

  @override
  State<FeedbackPage> createState() => _FeedbackPageState();
}

class _FeedbackPageState extends State<FeedbackPage> {
  late final WebViewController controller;

  static const listen = '''
    window.addEventListener("onestar:submit", function (e) {
      if (e.detail && e.detail.final) {
        OneStarFlutter.postMessage(JSON.stringify({ type: "onestar:submitted", form: e.detail.form }));
      }
    });
  ''';

  @override
  void initState() {
    super.initState();
    final url = Uri.https('onestar.sh', '/f/your-form-slug/embed', {
      'platform': Platform.isIOS ? 'ios' : 'android',
      'placement': 'settings',
    });
    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel('OneStarFlutter', onMessageReceived: (message) {
        final msg = jsonDecode(message.message) as Map<String, dynamic>;
        if (msg['type'] == 'onestar:submitted' && mounted) Navigator.of(context).pop();
      })
      ..setNavigationDelegate(NavigationDelegate(onPageFinished: (_) => controller.runJavaScript(listen)))
      ..loadRequest(url);
  }

  @override
  Widget build(BuildContext context) => Scaffold(body: WebViewWidget(controller: controller));
}
```

Add the channel before `loadRequest`: a channel takes effect on the next page load. Do not name it `onestar`, which the page already uses for its own JavaScript API. The `onestar:submit` event fires for every step the form saves; only the one with `final` set means the person finished.

## What the page records

The page is the same form as everywhere else. Answers from it show in Results with your app's version, platform and placement, and each time the page draws the form it counts as one load for the funnel on Results, under OneStar's site and your placement. Beyond what you put in the query, the page keeps and sends only what the widget does on any site: see [Allowed sites and security](https://onestar.sh/docs/feedback/security.md).
