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#
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. |
https://onestar.sh/f/<slug>/embed?app_version=4.2.0&platform=ios&placement=settingsEach 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 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). Pass it after #, never in the query:
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:
{ "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:
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:
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:
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:
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.