# Ruby Native full documentation > Turn your Rails app into iOS and Android apps. Any frontend framework. No Xcode or Android Studio required. -------------------------------------------------------------------------------- Page: Documentation URL: https://rubynative.com/docs -------------------------------------------------------------------------------- # Documentation Learn how to get the most out of Ruby Native. These guides cover common features you can add after your first preview. Try Ruby Native ## Getting started - [Setup](/docs/setup) - configure your app and layout - [Tabs](/docs/tabs) - tab bar, eager loading, and tab routing - [Icons](/docs/icons) - SF Symbols on iOS and Material Symbols on Android - [Navigation bar](/docs/navbar) - native navbar with buttons, menus, and submit buttons - [Form handling](/docs/forms) - skip form pages when navigating back - [Keyboard](/docs/keyboard) - hide the iOS toolbar above the keyboard - [Inertia (React & Vue)](/docs/inertia) - setup for apps using React or Vue instead of ERB ## More features - [Appearance](/docs/appearance) - colors, dark mode, edge-to-edge content, and landscape orientation - [Error screen](/docs/error-screen) - customize and translate the offline and error fallback screens - [Authentication](/docs/authentication) - keep sessions alive for native users - [OAuth](/docs/oauth) - sign in with Google, GitHub, and other providers - [Linked domains](/docs/linked-domains) - tap-to-open in app and password autofill (iOS) - [Back buttons](/docs/back-buttons) - add native back navigation with a single CSS class - [Root pages](/docs/root-pages) - land a page with nothing behind it and no back button - [Floating action button](/docs/fab) - native FAB above the tab bar - [Menus](/docs/menus) - attach native menus to any element on the page - [Barcode scanner](/docs/barcode-scanner) - scan QR codes and barcodes with the device camera - [Haptics](/docs/haptics) - trigger device vibrations on user interaction - [Push notifications](/docs/push-notifications) - request permission, register tokens, and send notifications - [Badges](/docs/badges) - update app icon and tab bar badges from page loads - [Toasts](/docs/toasts) - show flash notices as native toasts - [In-app review](/docs/review) - ask for an App Store or Google Play rating in-app - [In-app purchases](/docs/in-app-purchases) - sell subscriptions through StoreKit - [Permissions](/docs/permissions) - camera, photo library, and microphone access - [Screenshots](/docs/screenshots) - capture phone screenshots for both stores from one flow - [CLI](/docs/cli) - deploy builds and auto-deploy from CI ## Native screen transitions Want push/pop animations and swipe-to-go-back between screens? [Advanced Mode](/docs/advanced-mode) adds native navigation transitions on top of everything above. No extra JavaScript or dependencies, just a one-line change in `config/ruby_native.yml`. ## iOS The [iOS guides](/docs/ios) cover enrolling in the Apple Developer Program, setting up App Store Connect, and the credentials Ruby Native needs to build your app. ## Android The [Android guides](/docs/android) cover creating your Google Play Developer account, connecting Play Console, and internal testing. ## Go live Ready to ship? The pre-flight checklists for [the App Store](/docs/ios/go-live) and [Google Play](/docs/android/go-live) cover payments, privacy questionnaires, store listings, and review. -------------------------------------------------------------------------------- Page: Setup URL: https://rubynative.com/docs/setup -------------------------------------------------------------------------------- # Setup ## Installation Add the gem to your Gemfile and run the install generator: ```sh bundle add ruby_native rails generate ruby_native:install ``` The generator creates `config/ruby_native.yml` and prints instructions for updating your layout. ## Tabs Define your tab bar in `config/ruby_native.yml` with a title, path, and [icon](/docs/icons) for each tab. ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person ``` See the [tabs guide](/docs/tabs) for eager loading, tab routing, and the `auto_route` option. ## Entry path By default the app launches on the first tab's path. Set `entry_path` to override this. ```yaml app: entry_path: /inbox ``` If `entry_path` is not set, the app loads the first tab's path, then falls back to `/`. ## Layout Add `native_tabs_tag` to your layout `` to tell the app when to show the tab bar. Like all Ruby Native helpers, it must render inside the ``, not the ``. See the [tabs guide](/docs/tabs) for React and Vue examples. ```erb <%= native_tabs_tag if user_signed_in? %> ``` ### Stylesheet Include the gem's stylesheet in your layout ``. This provides utility classes for safe area layout and hides elements marked with `native-hidden`. ```erb <%= stylesheet_link_tag :ruby_native %> ``` On Sprockets apps, also add `//= link ruby_native.css` to `app/assets/config/manifest.js` or the tag raises an error. Propshaft apps need no extra step. ### Viewport meta tag Add `viewport-fit=cover` to your viewport meta tag. This is required for CSS `env(safe-area-inset-*)` variables to return real values when the web view extends behind the status bar. Without it they resolve to `0` and the `native-inset` classes silently add no padding, so content hides under the status bar. ```erb ``` ### Safe area layout The web view extends behind the status bar and Dynamic Island. Add the `native-inset` class to your main content wrapper so content clears the status bar at the top and the tab bar at the bottom. It stacks with your existing padding. ```erb
<%= yield %>
``` Use `native-inset-top` or `native-inset-bottom` if you only need one side. See the [appearance guide](/docs/appearance) for details and fixed navbar handling. ### Hide web-only UI Use `native_app?` to hide elements that the native app replaces, like a web navbar or footer. ```erb <%= render "navbar" unless native_app? %> <%= render "footer" unless native_app? %> ``` ### Check the native library version Use `native_version` to show or hide content based on the Ruby Native library version the app was built with, like a feature that needs a newer native shell. It returns `"0"` when the version is unknown and supports string comparisons. For the app's own version, use `native_app_version` below. ```erb <% if native_version >= "0.13" %> <%# Show features that require the 0.13 native shell %> <% end %> ``` ### Read app and device details Four more helpers expose what the app reports about itself and the device. Each returns a string for native requests and `nil` for web browsers. ```erb <%= native_app_version %> <%# "5.2", the app's marketing version %> <%= native_app_build %> <%# "35", the build number %> <%= native_os_version %> <%# "26.5.2", the iOS or Android version %> <%= native_os_build %> <%# "23F79", the OS build number %> ``` `native_app_build`, `native_os_version`, and `native_os_build` also return `nil` for app builds from before the User-Agent carried them. ## Usage reporting Each time your app launches, it sends a small ping to rubynative.com with the bundle ID, the platform, and a random identifier generated once per install. That's the whole payload. No user ID, no email, and nothing from your Rails session, so nothing in it identifies a person. The dashboard uses these pings to show active devices for the last 30 days, split by iOS and Android. It's the quickest way to see whether a release actually reached people. To turn it off, add a top-level `analytics: false` to `config/ruby_native.yml`. Leaving the key out keeps reporting on; only a literal `false` disables it. ```yaml analytics: false ``` Builds on Ruby Native 0.17.0 or later pick up the flag, so deploy a new build after changing it. If you leave reporting on, declare it on your [App Store privacy label](/docs/ios/go-live/app-privacy) and [Play Data safety form](/docs/android/go-live/data-safety). ## Next steps - [Tabs](/docs/tabs) - eager loading, tab routing, and the `auto_route` option - [Navigation bar](/docs/navbar) - add a native navbar with buttons and menus - [Appearance](/docs/appearance) - customize colors, dark mode, and edge-to-edge content - [Inertia (React & Vue)](/docs/inertia) - setup for apps using React or Vue instead of ERB -------------------------------------------------------------------------------- Page: Tabs URL: https://rubynative.com/docs/tabs -------------------------------------------------------------------------------- # Tabs Define your tab bar in `config/ruby_native.yml` with a title, path, and [icon](/docs/icons) for each tab. ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person ``` You may also omit `tabs` to hide the tab bar entirely. The app will load [`entry_path`](/docs/setup#entry-path) or fall back to `/`. A single tab behaves the same way: the bar needs at least two tabs to show. ![Tab bar on iOS](https://rubynative.com/docs/screenshots/tabs/ios.png) ## Showing and hiding the tab bar Add `native_tabs_tag` to your layout to tell the app when to show the tab bar. **ERB:** ```erb <%= native_tabs_tag if user_signed_in? %> ``` Pass `enabled: false` to dynamically hide the tab bar, for example during edit mode: ```erb <%= native_tabs_tag(enabled: !@editing) %> ``` **React:** ```jsx import { NativeTabs } from "@ruby-native/react" export default function Layout({ children }) { const { currentUser } = usePage().props return ( <> {currentUser && } {children} ) } ``` Pass `enabled={false}` to dynamically hide the tab bar, for example during edit mode: ```jsx ``` **Vue:** ```vue ``` Pass `:enabled="false"` to dynamically hide the tab bar, for example during edit mode: ```vue ``` ## Eager loading By default, each tab's content loads when the user first taps it. To load a tab immediately when the tab bar appears, set `eager: true`: ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person eager: true ``` The first tab always loads immediately (it is the visible tab). Use `eager` on other tabs that benefit from being ready when the user switches to them. ## Search tab Mark your app's search screen with `search: true`: ```yaml tabs: - title: Home path: / icon: house - title: Search path: /search icon: magnifyingglass search: true ``` iOS gives the tab the system search treatment: it sits apart from the other tabs with the search appearance, and tapping it loads the tab's `path` like any other tab. This currently applies on iOS in [Advanced Mode](/docs/advanced-mode) only. Normal Mode and Android read the option and ignore it, so it is safe to keep in your config while support catches up. ## Localization Tab titles can be translated in your app's own locale files, the same way [error screen copy](/docs/error-screen#error-messages) works. Give a tab a `key:` and put its copy under `ruby_native.tabs..title`. ```yaml tabs: - title: Home key: home path: / icon: house ``` ```yaml # config/locales/it.yml it: ruby_native: tabs: home: title: Casa ``` The device's language picks the translation. `title:` stays the fallback for a language you have not translated yet, so a tab always has a label. Once every tab has a key, you can drop `title:` and keep all of your copy in locale files. ```yaml tabs: - key: home path: / icon: house ``` Each tab needs its own key. Two tabs sharing one read the same translations, so one of them is always mislabeled. ## Tab routing When a user clicks a link that belongs to a different tab, the app automatically switches to that tab. This works with Turbo, Inertia, and plain HTML links for GET requests. Form submissions (POST, PUT, DELETE) always stay in the current tab, and server-side redirects are not intercepted. ![Tapping a link belonging to another tab auto-switches](https://rubynative.com/docs/screenshots/tabs/routing.gif) By default, each tab matches URLs that start with its `path`. A tab with `path: /inbox` matches `/inbox`, `/inbox/123`, `/inbox/archive`, etc. ### Disabling routing Set `auto_route: false` to disable routing for a tab entirely. ```yaml tabs: - title: Home path: / icon: house auto_route: false - title: Inbox path: /inbox icon: tray - title: Profile path: /profile icon: person ``` ### Custom route prefixes For more control, set `auto_route` to an array of route prefixes. This replaces the default prefix match on `path`. ```yaml tabs: - title: Explore path: /explore icon: binoculars auto_route: - /explore - /breweries/ - /neighborhoods/ - title: Passport path: /passport icon: wallet.bifold ``` A trailing slash means "only match sub-paths." `/breweries/` matches `/breweries/123` but not `/breweries`. Without the trailing slash, both would match. When multiple tabs match a URL, the longest prefix wins. If no tab matches, the link navigates within the current tab as usual. ### JavaScript API `RubyNative.visit()` is tab-aware. Call it to navigate to any URL and the app will switch tabs if needed. ```js RubyNative.visit("/inbox/123") // switches to Inbox tab and navigates RubyNative.visit("/profile") // switches to Profile tab ``` If the URL matches the current tab (or no tab at all), it navigates locally using Turbo, Inertia, or a standard page load. -------------------------------------------------------------------------------- Page: Icons URL: https://rubynative.com/docs/icons -------------------------------------------------------------------------------- # Icons Ruby Native uses native icon libraries for tab bar and button icons: [SF Symbols](https://developer.apple.com/sf-symbols/) on iOS and [Material Symbols](https://fonts.google.com/icons) on Android. Think of either as Font Awesome or Heroicons, but built into the platform. ## Setting an icon In your `config/ruby_native.yml`, set the `icon` for each tab. When the iOS and Android names match (most common icons), one value works for both. ```yaml tabs: - title: Home path: / icon: home - title: Profile path: /profile icon: person ``` When the names differ, use the `icons:` hash and give both platforms a name. Drop `icon:` when you do; it's the fallback for when one name covers both, not something `icons:` needs alongside it. ```yaml tabs: - title: New path: /new icons: ios: plus android: add ``` ## Cross-platform names The most popular choices for typical app navigation: | iOS (SF Symbol) | Android (Material Symbol) | Good for | |---|---|---| | `house` | `home` | Home, dashboard | | `magnifyingglass` | `search` | Search | | `plus` | `add` | Create, new item | | `person` | `person` | Profile, account | | `envelope` | `mail` | Messages, inbox | | `bell` | `notifications` | Notifications | | `gear` | `settings` | Settings | | `star` | `star` | Favorites, bookmarks | | `shippingbox` | `inventory_2` | Orders, packages | | `link` | `link` | Links, connections | | `building.2` | `business` | Company, organization | | `calendar` | `calendar_today` | Events, schedule | ## iOS (SF Symbols) Apple's built-in icon library. Names are case-sensitive and use dots as separators (e.g., `person.crop.circle`, not `personCropCircle`). Apple offers over 6,000 symbols. Two ways to browse them: - **[SF Symbols app](https://developer.apple.com/sf-symbols/) (Mac only):** Download the free SF Symbols app for a more complete browsing experience with search, categories, and weight previews. This is optional. You don't need a Mac to use SF Symbols with Ruby Native. - **[On the web](https://github.com/andrewtavis/sf-symbols-online/blob/master/README.md):** View a searchable gallery with names. ## Android (Material Symbols) Google's icon library, used by Android. Names are lowercase and use underscores as separators (e.g., `calendar_today`, not `calendarToday`). Google offers thousands of symbols. Browse them at [fonts.google.com/icons](https://fonts.google.com/icons). Copy the name shown beneath the icon and paste it into your config exactly as it appears. That name is the entire value, with no `@drawable/` prefix and no trailing size like `_24`. ## Tips - **Stick to the outline style.** Tab bars on both platforms use the outline variant by default and switch to the filled version for the active tab. Use `house`, not `house.fill`. - **Search by concept.** Looking for a "dashboard" icon? Try "chart", "gauge", or "rectangle.grid" on iOS, or "dashboard", "speed", or "view_module" on Android. Naming isn't always obvious. - **Use the underscore name on Android, not the display name.** "Calendar today" is the display name; `calendar_today` is what goes in the config. - **A name that doesn't exist renders a placeholder.** Both platforms fall back to a boxed question mark rather than drawing nothing, so a typo looks like a broken icon instead of an invisible button. - **Test with your tab bar.** Some symbols look great at large sizes but are hard to read at tab bar size. Stick to simple, recognizable shapes. -------------------------------------------------------------------------------- Page: Navigation bar URL: https://rubynative.com/docs/navbar -------------------------------------------------------------------------------- # Navigation bar You have two options for the navigation bar: a native one provided by Ruby Native, or your own web-based one. We recommend the native navigation bar for most apps. It uses liquid glass styling, includes a back button automatically, and supports buttons, menus, submit buttons, and share buttons. If you need full control over the navigation bar's layout and design, you can keep your web-based navbar instead. See [using a web navbar](#using-a-web-navbar) below. To put a centered logo and brand colors on the native bar across the whole app, see [navbar branding](/docs/appearance#navbar-branding). For Inertia setup, see the [Inertia guide](/docs/inertia) first. ![Native navigation bar with title on iOS](https://rubynative.com/docs/screenshots/navbar-title/ios.png) ## Native navigation bar ### Basic usage Add a navbar with a title to each page. Hide your web heading when native so the title isn't duplicated. **ERB:** ```erb <%# app/views/categories/index.html.erb %> <%= native_navbar_tag("Menu") %>

">Menu

``` **React:** ```jsx import { usePage } from "@inertiajs/react" import { NativeNavbar } from "@ruby-native/react" export default function Index() { const { nativeApp } = usePage().props return ( <> {!nativeApp &&

Menu

} ) } ``` **Vue:** ```vue ``` ## Buttons Add buttons to the navigation bar. Use `href` to navigate to a URL, or `click` to click a DOM element by CSS selector. ![Navbar with a trailing button on iOS](https://rubynative.com/docs/screenshots/navbar-button/ios.png) **ERB:** ```erb <%= native_navbar_tag("Menu") do |navbar| %> <%= navbar.button icons: { ios: "bag", android: "shopping_bag" }, href: cart_path %> <% end %> ``` **Inertia:** ```jsx ``` Builder calls work with `<%= %>` or `<% %>`; the methods collect items and render nothing inline either way. Prefer `<%= %>` if you run `erb_lint`, which flags a bare `<% %>` call as an unused expression. **Button options:** | Option | Type | Default | Description | |---|---|---|---| | `icon` | string | | [icon](/docs/icons) name (e.g., `"bag"`, `"plus"`, `"pencil"`). | | `icons` | hash | | Per-platform [icons](/docs/icons), e.g. `{ ios: "ellipsis.circle", android: "more_horiz" }`. A match overrides `icon`. | | `title` | string | | Text label. Use `icon` or `title`, not both. | | `href` | string | | URL to navigate to when tapped. | | `click` | string | | CSS selector of a DOM element to `.click()` when tapped. | | `position` | string | `"trailing"` | `"trailing"` (right), `"leading"` (left), or `"title"` (a dropdown on the title, see [Title menu](#title-menu)). | | `selected` | boolean | `false` | Renders the button in a selected/highlighted state. | A leading button shares its slot with the back button, and the back button wins: the leading button shows on screens with nothing to go back to, like a tab's root. Put actions that should stay reachable everywhere in `trailing`. ## Menus A button with children creates a dropdown menu. Each menu item uses `href` to navigate or `click` to click a DOM element. ![Navbar with an ellipsis menu button on iOS](https://rubynative.com/docs/screenshots/navbar-menu-closed/ios.png) **ERB:** ```erb <%= native_navbar_tag("Account") do |navbar| %> <%= navbar.button icons: { ios: "ellipsis.circle", android: "more_horiz" }, position: :leading do |menu| %> <%= menu.item "Edit profile", href: edit_account_path, icons: { ios: "pencil", android: "edit" } %> <%= menu.item "Sign out", click: "#sign-out-button", icons: { ios: "rectangle.portrait.and.arrow.right", android: "logout" }, destructive: true %> <% end %> <% end %> <%# Keep the web element in the DOM so the native menu can click it %> <%= button_to "Sign out", session_path, method: :delete, id: "sign-out-button", class: "native-hidden ..." %> ``` **Inertia:** ```jsx {/* Keep the web element in the DOM so the native menu can click it */} ``` **Menu item options:** | Option | Type | Description | |---|---|---| | `title` | string | The label shown in the menu. | | `href` | string | URL to navigate to when selected. | | `click` | string | CSS selector of a DOM element to `.click()` when selected. | | `icon` | string | Optional [icon](/docs/icons) name shown next to the title. | | `icons` | hash | Per-platform [icons](/docs/icons), e.g. `{ ios: "pencil", android: "edit" }`. A match overrides `icon`. | | `selected` | boolean | Renders a checkmark next to the item. | | `destructive` | boolean | Renders the item red, for delete-style actions. | | `action` | symbol | `:replace` visits `href` without stacking a back button, for switchers. | Menus also work anchored to the page's own content, not just the navigation bar. See [Menus](/docs/menus). ## Title menu Turn the title itself into a dropdown menu with `position: :title`. Tapping the title opens the menu, and the selected item shows a checkmark. It's the native counterpart of SwiftUI's `toolbarTitleMenu`, and unlike [segments](#segments) it works on both platforms and holds more than a handful of options, so it fits a sort or filter switcher well. ![Navigation bar title with a dropdown chevron on iOS](https://rubynative.com/docs/screenshots/navbar-title-menu-closed/ios.png) Give the button a `menu` and `position: :title`, with no icon or label of its own. Keep a title on `native_navbar_tag` and it stays as the label beside a dropdown chevron; omit the title and the selected item's text becomes the label. Pair the items with `action: :replace` so switching doesn't stack the back button, the same as segments. **ERB:** ```erb <%= native_navbar_tag("Inbox") do |navbar| %> <%= navbar.button position: :title do |menu| %> <%= menu.item "Sort by due date", href: inbox_path(sort: "due"), action: :replace, icon: "calendar", selected: @sort == "due" %> <%= menu.item "Sort by created", href: inbox_path(sort: "created"), action: :replace, icon: "clock", selected: @sort == "created" %> <% end %> <% end %> ``` **Inertia:** ```jsx ``` The items take the same [menu item options](#menus) as any navbar menu. A [navbar logo](/docs/appearance#navbar-branding) owns the center of the bar, so when one is configured the title menu, like segments, doesn't render. ## Segments Show up to three segmented buttons at the top of the screen to switch between closely related pages. For your app's primary sections, use the bottom [tab bar](/docs/tabs) instead. ![Segmented control in the navigation bar on iOS](https://rubynative.com/docs/screenshots/navbar-segments/ios.png) **ERB:** ```erb <%= native_navbar_tag do |navbar| %> <%= navbar.segment "Pledges", href: pledges_path, selected: true %> <%= navbar.segment "Digital Rewards", href: digital_rewards_path %> <% end %> ``` **Inertia:** ```jsx ``` Mark the current page's segment `selected`, and render the same set on each sibling page so the control stays in place as the user moves between them. Switching segments replaces the current history entry instead of stacking it, so the back button doesn't step back through segment switches. **Segment options:** | Option | Type | Default | Description | |---|---|---|---| | `title` | string | | The segment label. First positional argument. | | `href` | string | | URL to navigate to when tapped. | | `click` | string | | CSS selector of a DOM element to `.click()` when tapped, instead of `href`. | | `selected` | boolean | `false` | Marks the current page's segment. | Segments and a [navbar logo](/docs/appearance#navbar-branding) both occupy the center of the bar, so when a logo is configured the segments don't render. ## Submit buttons Add a native submit button that clicks the web form's submit button. The native button mirrors the web button's disabled state automatically, so it disables during form submission and re-enables when done. ![Submit button in navbar on iOS](https://rubynative.com/docs/screenshots/navbar-submit/ios.png) Keep the web submit button in the DOM (use the `native-hidden` class) so the native button can click it. **ERB:** ```erb <%# app/views/accounts/edit.html.erb %> <%= native_form_tag %> <%= native_navbar_tag("Edit profile") do |navbar| %> <%= navbar.submit_button title: "Save" %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeSubmitButton } from "@ruby-native/react" export default function Edit() { return ( <>
{/* ... */}
) } ``` **Vue:** ```vue ``` **Submit button options:** | Option | Type | Default | Description | |---|---|---|---| | `title` | string | `"Save"` | The button label in the navigation bar. | | `click` | string | `"[type='submit']"` | CSS selector for the web submit button to click. | ## Share buttons Add a button that opens the native share sheet. By default it shares the current page. Pass `url` to share a different link, and customize the label, icon, and position like any other navbar button. ![Navbar with a share button on iOS](https://rubynative.com/docs/screenshots/navbar-share/ios.png) **ERB:** ```erb <%= native_navbar_tag("Brewery") do |navbar| %> <%# Shares the current page %> <%= navbar.share_button %> <%# Or customize everything %> <%= navbar.share_button url: brewery_url(@brewery), title: "Send", icons: { ios: "square.and.arrow.up.circle", android: "share" }, position: :leading %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeShareButton } from "@ruby-native/react" {/* Shares the current page */} {/* Or customize everything */} ``` **Vue:** ```vue ``` **Share button options:** | Option | Type | Default | Description | |---|---|---|---| | `url` | string | current page | The link to share. Defaults to the current page's URL. | | `title` | string | `"Share"` | Accessibility label. Becomes the visible text when the button has no icon. | | `icon` | string | `"square.and.arrow.up"` | [icon](/docs/icons) name, applied to every platform. | | `icons` | hash | | Per-platform [icons](/docs/icons), e.g. `{ ios: "square.and.arrow.up", android: "share" }`. A match overrides `icon`. | | `position` | string | `"trailing"` | `"trailing"` (right) or `"leading"` (left; the back button wins the slot on pushed screens). | ### Share from a menu Put share inside a button's dropdown with `share_item` (`NativeShareMenuItem` in Inertia). It takes the share button's `url`, `title`, and `icon`/`icons` options, plus `selected` from the other menu items; `action` and `destructive` don't apply to share items. **ERB:** ```erb <%= native_navbar_tag("Brewery") do |navbar| %> <%= navbar.button icons: { ios: "ellipsis.circle", android: "more_horiz" } do |menu| %> <%= menu.item "Edit", href: edit_brewery_path(@brewery) %> <%= menu.share_item %> <% end %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeButton, NativeMenuItem, NativeShareMenuItem } from "@ruby-native/react" ``` **Vue:** ```vue ``` ## Pull to refresh The pull-to-refresh control is installed on every page rendering a native navbar. Drag down from the top of the page and release to refresh the current URL. Turbo and Inertia pages refresh correctly because both push state on navigation and the native WebView's URL tracks that. Pass `pull_to_refresh: false` to opt out on a specific page. Useful when a page has its own refresh affordance or an infinite scroll at the top that would conflict. **ERB:** ```erb <%= native_navbar_tag("Map", pull_to_refresh: false) %> ``` **Inertia:** ```jsx ``` HTMX users: pull-to-refresh reloads whatever URL is in the address bar, which may not match the current view after a partial swap. Use `hx-push-url="true"` on the swaps you want refresh to reach, or pass `pull_to_refresh: false` on pages that don't push state. ## Hiding web elements There are two ways to hide web elements when running in the native app: **`native-hidden` class:** The element stays in the DOM but is visually hidden. Use this for elements that need to remain clickable by native buttons, menus, or submit buttons. The `native-hidden` class requires the gem stylesheet (`<%= stylesheet_link_tag :ruby_native %>`). ```erb <%= link_to "Desktop page", desktop_path, class: "native-hidden" %> ``` **Server-side conditional (`unless native_app?`):** The element is not rendered at all. Use this for web-only UI that has no native equivalent, like a web navbar. ```erb <% unless native_app? %> <%= render "shared/footer" %> <% end %> ``` ## Using a web navbar If you need custom controls, branding, or a layout that the native navigation bar doesn't support, you can keep your web-based navbar. The native navigation bar is hidden by default, so if you don't add any `native_navbar_tag` or `NativeNavbar` signals, your web navbar will show as-is. ### Make it sticky Pin your web navbar to the top of the screen so it stays visible as users scroll. ```erb ``` Use `fixed top-0` (Tailwind) or `fixed-top` (Bootstrap) on your navbar element. ### Safe area padding Add `native-inset-top` to your fixed navbar element so its content clears the Dynamic Island. The navbar background extends behind the status bar automatically. See the [appearance guide](/docs/appearance) for safe area classes and layout details. -------------------------------------------------------------------------------- Page: Form handling URL: https://rubynative.com/docs/forms -------------------------------------------------------------------------------- # Form handling When a user submits a form and the app navigates to a success page, tapping "back" should skip the form and return to the page before it. Ruby Native marks form pages so the app skips them in the back stack. In [Advanced Mode](/docs/advanced-mode), you can also add a native submit button to the navigation bar by nesting `navbar.submit_button` inside a `native_navbar_tag`. ![Tapping back skips the form page](https://rubynative.com/docs/screenshots/forms/ios.gif) ## How it works Mark any page that contains a form. The app records it as a form page and skips over it when navigating back. Add this to every form page: new, edit, sign in, sign up, and any other page with a form submission. Signal elements must be in the ``, not the ``. The app detects them via a MutationObserver on the body element. ## Usage **ERB:** ```erb <%# app/views/links/new.html.erb %> <%= native_form_tag %>

New link

<%= render "form", link: @link %> ``` **React:** Set `@native_form = true` in your controller action. The `InertiaSupport` concern shares it automatically as `nativeForm`. ```ruby # app/controllers/habits_controller.rb def new @native_form = true render inertia: "Habits/New", props: { habit: Habit.new } end ``` Then render the `NativeForm` component on form pages: ```jsx import { NativeForm } from "@ruby-native/react" export default function New({ habit, errors }) { return ( <>
{/* ... */}
) } ``` **Vue:** Set `@native_form = true` in your controller action. The `InertiaSupport` concern shares it automatically as `nativeForm`. ```ruby # app/controllers/habits_controller.rb def new @native_form = true render inertia: "Habits/New", props: { habit: Habit.new } end ``` Then render the `NativeForm` component on form pages: ```vue ``` ## Native submit button You can also add a submit button to the [navigation bar](/docs/navbar#submit-buttons) that clicks the web form's submit button and mirrors its disabled state. See the [navbar guide](/docs/navbar#submit-buttons) for details. -------------------------------------------------------------------------------- Page: Keyboard URL: https://rubynative.com/docs/keyboard -------------------------------------------------------------------------------- # Keyboard By default, iOS renders a toolbar above the keyboard whenever a form field is focused, with previous and next arrows and a Done button. Disable it with the `native_keyboard_tag` tag. ![A comment field with the previous, next, and Done toolbar above the keyboard](https://rubynative.com/docs/screenshots/keyboard-toolbar/ios.png) ## Basic usage Add the tag to any page whose keyboard should have no toolbar. **ERB:** ```erb <%# app/views/messages/index.html.erb %> <%= native_keyboard_tag(toolbar: false) %> <%= form_with model: @message do |form| %> <%= # ... %> <% end %> ``` **React:** ```jsx import { NativeKeyboard } from "@ruby-native/react" export default function Index() { return ( <>
{/* ... */}
) } ``` **Vue:** ```vue ``` Passing `toolbar: true` renders nothing, so a shared layout can decide per page without an `if`: ```erb <%= native_keyboard_tag(toolbar: !@chat) %> ``` The Inertia components take the same option as a prop: `` in React, `` in Vue. ## It applies to the page, not the field The tag is page-level: it hides the toolbar for every field on that page. Nothing carries over to the next page. A page without the tag brings the toolbar back as soon as you navigate to it, including a page with no Ruby Native tags at all. ## iOS only Android's keyboard has no equivalent toolbar, so the tag changes nothing there and pages that use it need no Android-specific handling. -------------------------------------------------------------------------------- Page: Inertia (React & Vue) URL: https://rubynative.com/docs/inertia -------------------------------------------------------------------------------- # Inertia (React & Vue) Ruby Native includes first-class support for Inertia.js apps using React or Vue. This guide covers the one-time setup. Once configured, all the feature guides (navbar, forms, badges, etc.) include Inertia examples alongside ERB. ## 1. Include the concern Add `RubyNative::InertiaSupport` to your application controller. It shares `nativeApp` and `nativeForm` props automatically via `inertia_share`. ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base include RubyNative::InertiaSupport end ``` Your own `InertiaSharedData` concern handles app-specific props (flash, current_user, etc.). The gem concern only shares what it owns. ## 2. Install the npm package React and Vue components ship as two scoped npm packages under the Ruby Native org. Install the one that matches your frontend. **React:** ```sh npm install @ruby-native/react ``` **Vue:** ```sh npm install @ruby-native/vue ``` Import the components directly in your pages and layouts. **React:** ```jsx import { NativeTabs, NativePush, NativePresentation } from "@ruby-native/react" ``` **Vue:** ```js import { NativeTabs, NativePush, NativePresentation } from "@ruby-native/vue" ``` ## 3. Use the `nativeApp` prop The `InertiaSupport` concern shares a `nativeApp` boolean prop on every page. Use it to hide web-only elements when the native app is connected. **React:** ```jsx import { usePage } from "@inertiajs/react" export default function Index() { const { nativeApp } = usePage().props return ( <> {!nativeApp &&

Habits

} {/* ... */} ) } ``` **Vue:** ```vue ``` This is the Inertia equivalent of the `native_app?` helper used in ERB views. ## 4. Check the platform with `nativePlatform()` Both packages also export `nativePlatform()`, which reads the User-Agent and returns `"ios"`, `"android"`, or `null` on the web. Reach for it when an element belongs on one platform only, or to hide native-only controls like the [scan button](/docs/barcode-scanner) from web visitors. **React:** ```jsx import { nativePlatform } from "@ruby-native/react" {nativePlatform() && } ``` **Vue:** ```vue ``` Unlike the `nativeApp` prop, `nativePlatform()` runs client-side, so it also works outside a page component: in a shared layout, a plain script, or an event handler. During server-side rendering it returns `null`. ## Feature guides With setup complete, follow the feature guides to add native functionality. Each guide includes Inertia examples alongside ERB. - [Setup](/docs/setup) - tabs and layout - [Tabs](/docs/tabs) - show and hide the tab bar - [Navigation bar](/docs/navbar) - native navbar with buttons, menus, and submit buttons - [Form handling](/docs/forms) - skip form pages when navigating back - [Keyboard](/docs/keyboard) - hide the iOS toolbar above the keyboard - [Authentication](/docs/authentication) - reset the app on sign-out - [Back buttons](/docs/back-buttons) - native back navigation - [Root pages](/docs/root-pages) - land a page with nothing behind it - [Floating action button](/docs/fab) - native FAB above the tab bar - [Menus](/docs/menus) - native menus anchored to page content - [Barcode scanner](/docs/barcode-scanner) - scan with the device camera - [Haptics](/docs/haptics) - device vibrations on user interaction - [Push notifications](/docs/push-notifications) - request permission and register tokens - [Badges](/docs/badges) - app icon and tab bar badges - [Toasts](/docs/toasts) - flash notices as native toasts - [In-app review](/docs/review) - ask for a store rating in-app -------------------------------------------------------------------------------- Page: Appearance URL: https://rubynative.com/docs/appearance -------------------------------------------------------------------------------- # Appearance Customize your app's colors and layout in `config/ruby_native.yml`. ```yaml appearance: tint_color: "#007AFF" background_color: "#FFFFFF" ``` The `tint_color` controls the active tab icon color and any tinted UI elements. The `background_color` is used as the window background, visible during app launch and view transitions. Set it to match your CSS body background to avoid a flash of white before content loads. If omitted, the default is white. ## Theme By default, the app follows the device's system appearance. Set `theme` to force light or dark mode regardless of the user's device setting. ```yaml appearance: theme: light ``` | Value | Behavior | |---|---| | `auto` | Follows the device setting (default) | | `light` | Always light mode | | `dark` | Always dark mode | When omitted, `theme` defaults to `auto`. ## Dark mode Color fields accept a plain hex string or an object with `light` and `dark` keys. ```yaml appearance: tint_color: "#007AFF" background_color: light: "#FFFFFF" dark: "#212529" ``` The app picks the matching value based on the current appearance. This follows the device setting by default, or the forced `theme` if you set one. If you pass a plain string instead of an object, that color is used for both modes. ## Navbar branding Brand the navigation bar across your whole app: a centered logo in place of the page title, your own bar colors, and a matching status bar. ```yaml appearance: navbar: logo: "<%= image_url('logo.png') %>" background_color: "#3B3F54" foreground_color: "#FFFFFF" status_bar: light ``` Everything in the `navbar` block is optional. Set only the colors for a themed bar with text titles, or add a logo to replace those titles. ### Logo Set `logo` with `image_url`. `config/ruby_native.yml` is evaluated as ERB, and the fingerprinted URL means the app downloads the logo once, caches it, and re-downloads it only when the image changes. ```yaml logo: "<%= image_url('logo.png') %>" ``` A full URL works too, for a logo hosted on a CDN. Use a PNG (or any raster format) with a transparent background; SVGs are not supported. The logo renders about 30 pt tall with its width following the aspect ratio, so export it at roughly 3x that (around 90 to 120 px tall) and keep it a compact wordmark so it doesn't crowd the back button. The logo replaces the page title and stays centered on every screen, including during the native push and pop transitions in [Advanced Mode](/docs/advanced-mode). Leading and trailing [buttons](/docs/navbar#buttons) added with `native_navbar_tag` still appear as usual. ### Bar colors `background_color` sets the bar's background, and `foreground_color` sets the title and bar button color. Each accepts a hex string or a `{ light:, dark: }` object, like the [dark mode](#dark-mode) colors above. ### Status bar Set `status_bar` to keep the clock and system icons readable against your bar: `light` for white content on a dark bar, `dark` for dark content on a light bar. Omit it to let the system decide; it is not derived from your bar color. ## Android chrome colors On Android, the navigation bar, tab bar, and floating action button are a neutral gray, light or dark to match the theme and sitting one step off the page background, with your `tint_color` on the selected tab and accents. Customize your navigation bar or tab bar under the `android` key: ```yaml appearance: android: navbar: background_color: "#1E293B" foreground_color: "#FFFFFF" tab_bar: background_color: "#1E293B" foreground_color: "#FFFFFF" ``` Each accepts a hex string or a `{ light:, dark: }` object, like the [dark mode](#dark-mode) colors above. On Android these win over the cross-platform [navbar branding](#navbar-branding) colors. The navbar's `foreground_color` sets the title and icon color; when omitted it's black or white, picked for contrast against the bar. The tab bar's `foreground_color` sets every tab icon and label. The selected tab uses it at full strength and the rest dim to 60% of it, so one color covers both states. When omitted, the selected tab takes your `tint_color` and the rest follow the Material theme, which is hard to read on a dark branded bar in light mode. These settings never touch iOS. Its bars are Liquid Glass, which Apple doesn't let apps recolor, so they always keep the system look. ## Splash screen On iOS, show the launch screen with an activity indicator while the app loads, instead of flashing to a blank web view. Set `enabled: true` to turn it on. Android needs no additional configuration. The system splash screen and your icon on your background color always remain visible until the first page renders. ```yaml appearance: splash: enabled: true spinner_color: "#007AFF" status_bar: light ``` Everything except `enabled` is optional: - `spinner_color` sets the color of the activity indicator. By default it picks black or white from your `background_color` so it stays visible; override it with your own color, such as a brand accent. Accepts a hex string or a `{ light:, dark: }` object, like the [dark mode](#dark-mode) colors above. - `status_bar` sets the status bar content over the splash: `light` for white content on a dark background, `dark` for dark content on a light one. By default it is chosen to stay readable against your `background_color`. The splash shows on every launch except the very first after install, when your config hasn't been cached yet. Android doesn't read this block. It always shows the system splash screen, holding your launch icon on your `background_color` until the first screen paints, so there's nothing to enable and no spinner to color. ## Edge-to-edge content The web view always extends behind the status bar and Dynamic Island. Your web page controls the entire screen, including the area behind system UI. Add `viewport-fit=cover` to your viewport meta tag so CSS `env(safe-area-inset-*)` variables return real values. ```erb ``` ### Safe area CSS classes The gem stylesheet provides utility classes to add safe area spacing. These stack with your existing padding and margin utilities. ```erb <%= stylesheet_link_tag :ruby_native %> ``` | Class | Effect | |---|---| | `native-inset` | Adds safe area spacing at top and bottom | | `native-inset-top` | Adds safe area spacing at top only | | `native-inset-bottom` | Adds safe area spacing at bottom only | ```erb
<%= yield %>
``` These classes use `::before` and `::after` pseudo-elements, so they stack with padding utilities like `pb-8` without conflicting. Background colors extend through the inset area. ### Fixed navbars For `position: fixed` navbars, use `native-inset-top` directly on the navbar element. The navbar background extends behind the status bar while its content is pushed below the Dynamic Island. ```erb ``` The content area below a fixed navbar still needs its own top padding to clear the navbar, plus `native-inset-top` for the safe area. ### Fixed overlays Navbars pinned to `top: 0` can use `native-inset-top` because the spacer pushes their content down. Overlays anchored at an offset can't: a banner at `top: 0.75rem` or a toast stack near the top of the screen needs the offset itself to account for the safe area. Anchor these with `max()` and the gem's safe area variables: ```css .banner { position: fixed; top: max(0.75rem, calc(var(--ruby-native-safe-area-top) + 0.5rem)); } ``` Or inline with Tailwind: ```erb
``` The same applies to full-screen modals: give the modal container `padding-top: max(0.75rem, var(--ruby-native-safe-area-top))` so its header and close button sit below the status bar. `--ruby-native-safe-area-top` and `--ruby-native-safe-area-bottom` come from the gem stylesheet and resolve everywhere. On iOS they read `env(safe-area-inset-top)`, on Android the app pushes in the real system bar heights, and on the web they fall back to `0px`, so they're safe in CSS that also serves your website. Don't use raw `env(safe-area-inset-*)` for these rules. Android's WebView doesn't populate it from system bars, so an overlay that looks right on iOS silently sits under the Android status bar. ## Native-feeling taps Web defaults give an app away: a gray flash on every tap, text selection on a long-pressed button, and a zoom on an accidental double tap. The gem stylesheet turns those off inside the app, and only there, so your website keeps its normal behavior: - No tap highlight flash on links and buttons. - No text selection when long-pressing buttons and labels. - No double-tap zoom on tappable elements. Pinch zoom on content still works. - `:active` styles apply instantly, so pressed states feel immediate. There's nothing to configure. The rules apply automatically whenever your layout includes the gem stylesheet: ```erb <%= stylesheet_link_tag :ruby_native %> ``` For pressed feedback that matches native, style `:active` on your buttons and rows, for example a slightly darker background or reduced opacity. ## Overscroll colors When a page rubber-bands past its edges, the area revealed behind it is the window `background_color`. That breaks down when a page has, say, a dark header and a light footer: one color can't match both edges. `native_overscroll_tag` sets the colors revealed beyond the top and bottom of a specific page. **ERB:** ```erb <%= native_overscroll_tag top: "#1E293B", bottom: "#FFFFFF" %> ``` **React:** ```jsx import { NativeOverscroll } from "@ruby-native/react" ``` **Vue:** ```vue ``` `bottom:` is optional and falls back to `top:`, so one color covers both edges. Like the other signal helpers it applies per page, so each screen can match its own design. Works on both platforms, in both modes. ## Landscape orientation By default, apps run in portrait only on phones. Set `landscape: true` to let phones rotate into landscape left and landscape right as well. ```yaml appearance: landscape: true ``` | Value | Behavior on phones | |---|---| | `false` or omitted | Portrait only (default) | | `true` | Portrait, landscape left, and landscape right | Tablets always rotate freely, regardless of this setting. iPads and newer Android tablets ignore orientation locks at the system level, so locking would only apply to older Android tablets and split behavior by OS version. Make sure your layouts work at landscape widths before enabling this, and at tablet widths if you support tablets. Test fixed-width elements, full-height sections, and any CSS that assumes a narrow viewport. -------------------------------------------------------------------------------- Page: Error screen URL: https://rubynative.com/docs/error-screen -------------------------------------------------------------------------------- # Error screen When the app fails to load a page it shows a native screen with an icon, a short message, and a retry button. You can customize the icon and copy and translate the copy into any language your app supports. Customize only what you want to change to override the English defaults. ## The two types of errors | Type | When it shows | |---|---| | `offline` | The device has no network connection: airplane mode or no signal | | `generic` | Any other failure to load: a 500 response, an unreachable server, or a request time out | ## Error messages Title and message copy lives in your app's own locale files, under the `ruby_native` namespace. This reuses the I18n setup you already have, so error copy sits alongside the rest of your app's strings. ```yaml # config/locales/en.yml en: ruby_native: errors: retry: "Retry" offline: title: "You're offline" message: "Check your connection and try again." generic: title: "Something went wrong" message: "Please try again in a moment." ``` The `retry` label is shared by both states. ## Icons Set [per-state icons](/docs/icons) in `config/ruby_native.yml`. ```yaml errors: offline: icons: ios: wifi.slash android: wifi_off generic: icons: ios: exclamationmark.triangle android: error_outline ``` ## Testing your changes Custom icons and copy are for your users, so they only appear in your built app. The Ruby Native app used for previews always shows the developer error screen with the real failure details instead; that's expected, not a sign your config is being ignored. To see what users see, trigger an error in a TestFlight or internal testing build, for example by turning on airplane mode. -------------------------------------------------------------------------------- Page: Authentication URL: https://rubynative.com/docs/authentication -------------------------------------------------------------------------------- # Authentication Ruby Native reuses your existing sign in and registration screens in the native app. There's no need to build separate native auth flows. Your web forms, validation, error messages, and forgot password pages all work as-is. There are two things to get right: keeping sessions alive so native users aren't logged out unexpectedly, and telling the app who is signed in so signing out clears their screens. If your app uses OAuth (Sign in with Google, GitHub, etc.), see the [OAuth guide](/docs/oauth). ## Rails authentication generator If you're using the built-in Rails authentication generator, set the session cookie to permanent. ```ruby cookies.signed.permanent[:session_id] = session.id ``` ## Devise Always remember native users by adding a hidden field to your sign-in form. ```erb <%= form_with url: session_path do |form| %> <% if native_app? %> <%= form.hidden_field :remember_me, value: true %> <% else %> <%= form.check_box :remember_me %> <%= form.label :remember_me %> <% end %> <% end %> ``` ## Sign out The native app keeps screens alive between page loads. When signing out, other tabs need their context cleared. Tell the app who is signed in with `native_identity_tag`, and it will clear everything when they sign out or switch accounts. Render the tag on every page, even when the user is signed out: ```erb <%= native_identity_tag current_user&.id %> ``` The value rendered with this tag is hashed using your app's `secret_key_base`, so it won't expose any information. ### Account and team switching Pass an array to widen the identity boundary: ```erb <%= native_identity_tag [current_user&.id, current_team&.id] %> ``` Use this when switching teams should clear the screens, not just re-render the current one. ### Behavior The app resets when the identity is removed or changes: signing out or switching accounts. Signing in never resets, so a login reached deep in a checkout flow keeps its place. A page that renders no tag at all changes nothing in either direction, so a template that misses the layout can never sign anyone out by accident. ### Inertia apps The token is computed server-side so `secret_key_base` never reaches the client. Share it as a prop alongside your other shared data: ```ruby # app/controllers/concerns/inertia_shared_data.rb inertia_share do { native_identity: helpers.native_identity_token(current_user&.id), # ... } end ``` Don't reach for the ERB helper here: Inertia visits never re-render the Rails layout, so an element placed there would keep the old token across a sign-out. Attach the element in `createInertiaApp`'s `resolve` wrapper, never only in your main layout component. Auth and landing pages opt out of layouts, and a signed-out page missing the element makes sign-out invisible to the app. The React version wraps whatever layout a page chose; the Vue version leans on Inertia's nested layout arrays to do the same. **React:** ```jsx // app/frontend/entrypoints/inertia.jsx import { createInertiaApp } from "@inertiajs/react" import { createRoot } from "react-dom/client" import Layout from "~/layouts/Layout" const pages = import.meta.glob("../pages/**/*.jsx", { eager: true }) createInertiaApp({ resolve: (name) => { const page = pages[`../pages/${name}.jsx`] if (!page) throw new Error(`Page not found: ${name}`) if (!page.default.layout?.__withIdentity) { const layout = page.default.layout || ((page) => {page}) const wrapped = (page) => ( <> ``` ### Signal elements | Attribute | Description | |-----------|-------------| | `data-native-purchase="PRODUCT_ID"` | Container for a purchasable product. Tapping the submit button inside triggers StoreKit. | | `data-native-customer-id="ID"` | Your user identifier. Passed back in the `owner_token` event field. | | `data-native-success-path="/path"` | Where to navigate after a successful purchase. | | `data-native-price="PRODUCT_ID"` | Replaced with the localized price from Apple (e.g., "$9.99"). | | `data-native-restore` | Tapping this element restores previous purchases. | The `data-native-price` element shows a fallback price in HTML until Apple loads the real price. This means your paywall works on the web too. ## 5. Deploy Deploy your Rails app and trigger a new build in Ruby Native. The build automatically enables StoreKit when synced products exist. ## Testing Apple provides a sandbox environment for testing purchases without real charges. Create a [sandbox tester account](https://appstoreconnect.apple.com/access/testers) in App Store Connect and sign in on your test device under Settings, then App Store, then Sandbox Account. Sandbox subscriptions renew on an accelerated schedule: | Duration | Sandbox renewal | |----------|----------------| | 1 week | 3 minutes | | 1 month | 5 minutes | | 3 months | 15 minutes | | 6 months | 30 minutes | | 1 year | 1 hour | -------------------------------------------------------------------------------- Page: Permissions URL: https://rubynative.com/docs/permissions -------------------------------------------------------------------------------- # Permissions Both iOS and Android require apps to declare which sensitive capabilities they need. Ruby Native uses one set of dashboard fields to configure permissions for both platforms. ## When you need them **Camera and photo library** come into play when your app has a file input (``). Tapping it opens a picker that can take a new photo (camera) or choose an existing one (photo library). Declare whichever ones your app offers. A library-only upload doesn't need camera. **Microphone** is required if your app captures audio, such as voice notes or audio messages, or records video with sound. If your app doesn't use any of these features, leave the fields blank. Ruby Native excludes the permissions from your build so the system never prompts the user. ## Adding permissions In your app settings, fill in the **Permissions** section for each permission your app uses. The two platforms treat the text differently: - **iOS** shows your text in the system permission dialog the first time the feature is used. - **Android** shows its own generic prompt and never displays your text. There, a non-blank camera or microphone value tells Ruby Native to declare that capability in your manifest. Photo library needs no Android permission at all; uploads go through the system picker, so that field only affects iOS. Leave a field blank to skip that permission. Good examples: - "Take a photo for your profile picture." - "Choose photos to attach to your listing." - "Record video to share with your team." Keep it specific. Apple reviews these strings during App Store review and recommends being clear, specific, and honest about how the data will be used. See [Write clear purpose strings](https://developer.apple.com/videos/play/tech-talks/110152/) for their best practices. On Android, if you declare camera or microphone access, you'll also declare that data in your Play Console **Data safety** form when you promote a build to production. That's part of going live, not per-permission setup. See [data safety](/docs/android/go-live/data-safety) and [Play submission](/docs/android/go-live/submission). -------------------------------------------------------------------------------- Page: Debugging URL: https://rubynative.com/docs/debugging -------------------------------------------------------------------------------- # Debugging Ruby Native reads your pages through signals: hidden elements carrying `data-native-*` attributes that tell the app to show a tab bar, a navigation bar, a badge, and so on. The helpers in your views render them for you. Signals are ignored when the app does not recognize them. That is what lets the same page work in a browser and in the app, and it is why a mistyped signal never raises anything. The page renders, the app opens, and the tab bar is simply missing. This page covers the two places Ruby Native tells you about that: a warning while you are testing, and a command you can run before a build. ## Warnings in the Ruby Native app When you open your site in the Ruby Native app, it checks every page it renders and shows a warning toast when something looks wrong: > ⚠️ Ruby Native: unknown data-native-tabss, duplicate data-native-form It appears on both platforms, shows at most two problems with a `+2 more` suffix beyond that, and stays quiet if you revisit a page with the same problems. A new problem warns again. **This only ever appears in the Ruby Native app.** Your own builds drop it silently, so your users never see it. That also means it will not help you once you have moved off `ruby_native preview`, which is what the next section is for. ## Checking before a build `ruby_native check` reads every view in your app and reports the same problems, without waiting for someone to open the page: ```sh bundle exec ruby_native check ``` ``` app/views/layouts/native.html.erb 12: error Unknown signal `data-native-tabss`. Did you mean `data-native-tabs`? 31: warning 2 elements carry `data-native-form`; only the first one is used. Checked 48 templates: 1 error, 1 warning. ``` It exits 1 on an error and 0 otherwise, and `deploy` runs it for you before triggering a build. See the [CLI reference](/docs/cli#check) for the options. `check` needs the `herb` gem, which Rails 8.2 and later already include through Action View. On earlier versions, add `gem "herb"` to your Gemfile. Running it on every commit is the point, and it needs no token. See [Continuous integration](/docs/continuous-integration). ## What goes wrong | Problem | What you see | Fix | |---|---|---| | Mistyped signal | Nothing happens | Use the helper (`native_tabs_tag`) rather than writing the attribute by hand | | Duplicated signal | Only the first one works | Some signals are read once per page. Render it in one place, usually the layout | | Signal is too new | Nothing happens | The signal was added in a later version of Ruby Native than your Gemfile has. Update the gem | | Signal is newer than your build | Works locally, not for users | Your users have an older build. Deploy, or check with `ruby_native check --deployed` | ### The signal is there and still nothing happens The most common cause is a layout that does not render it on every request. Ruby Native reads the page it is given, so a tab bar signal inside a conditional that is false, or in a layout that a particular action skips, reads as "this page has no tabs" and the app removes the tab bar accordingly. Rendering the signal from your native layout, unconditionally, is the reliable shape: ```erb <%# app/views/layouts/native.html.erb %> <%= native_tabs_tag %> ``` If a signal should only apply to signed-in users, gate the whole layout rather than the signal inside it. ## Checking against the app your users have The gem in your Gemfile says what your views may emit. The build in the store says what your app can honor, and those drift apart as soon as you update the gem without deploying. ```sh bundle exec ruby_native check --deployed ``` This compares your views against the most recent build for each platform and reports signals that will not work for anyone who has already installed your app. It needs you to be logged in, or `RUBY_NATIVE_TOKEN` set. -------------------------------------------------------------------------------- Page: Continuous integration URL: https://rubynative.com/docs/continuous-integration -------------------------------------------------------------------------------- # Continuous integration Once you've integrated Ruby Native you'll want to verify that the native side is correct before a build goes out. You can now check it yourself, in your own CI pipeline, on every commit, with `ruby_native check`. ## Validating your integration `ruby_native check` reads every view in your app and reports native integration problems. It lists signals the app will not recognize, signals rendered more than once where only the first counts, and signals that need a newer version of Ruby Native than your Gemfile has. ```yaml # .github/workflows/ci.yml - name: Check Ruby Native signals run: bundle exec ruby_native check ``` It exits 1 when it finds an error and 0 otherwise, so a broken integration fails the build like any other test. See [Debugging](/docs/debugging) for what each problem means. `check` requires the `herb` gem, which Rails 8.2 and later already include through Action View. On earlier versions, add `gem "herb"` to your Gemfile. It scans `app/views` for `.html.erb` templates. Point it somewhere else, or at more than one place, with `--paths`: ```sh bundle exec ruby_native check --paths=app/views,app/components ``` Signals rendered from Phlex, from an Inertia page, or from a template that is not ERB are outside what it can read. The Ruby Native app still reports those at runtime while you are testing, which [Debugging](/docs/debugging) covers. ## Catching feature drift The gem in your Gemfile says what your views may emit. But the builds in the app stores declare what your app can render. Those can start to drift when you update the gem without deploying. Add your Ruby Native API token to compare your views against the most recent build for each platform. This reports anything your users' app is too old to understand. It is recommended to run this on your default branch rather than on every pull request. It is a question about what is shipped, not about the change in front of you. ```yaml - name: Check against the deployed build run: bundle exec ruby_native check --deployed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` ## Rebuilding when the gem changes Once your views are validated, the same workflow can trigger the native build with `--if-needed`. ```yaml - name: Deploy to Ruby Native run: bundle exec ruby_native deploy --if-needed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` `deploy` builds every platform you have configured, and `--if-needed` compares your gem version against the last successful build for each one, skipping only when they are all current. Now, **every Rails deploy ensures the mobile apps are up to date**. ## A complete workflow Generate an API token by running `ruby_native login` locally and copying it from `~/.ruby_native/credentials`, then store it as a repository secret. Copy this workflow to your Rails repository to check and release new app versions on every Rails deploy. ```yaml # .github/workflows/ci.yml name: CI on: pull_request: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: bundler-cache: true - run: bin/rails test - name: Check Ruby Native signals run: bundle exec ruby_native check - name: Check against the deployed build if: github.ref == 'refs/heads/main' run: bundle exec ruby_native check --deployed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} - name: Deploy to Ruby Native if: github.ref == 'refs/heads/main' run: bundle exec ruby_native deploy --if-needed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` -------------------------------------------------------------------------------- Page: Screenshots URL: https://rubynative.com/docs/screenshots -------------------------------------------------------------------------------- # Screenshots Both Apple and Google require screenshots before you can publish a production listing. They're often the first thing a potential user sees in store search results. ## How Ruby Native captures screenshots When you click **Capture** on the Screenshots page, Ruby Native boots a real device on its CI infrastructure (an iOS Simulator for iOS, an Android emulator for Android), launches your app, signs in as a designated user, navigates each path you've configured, and captures a screenshot at the native resolution of the device. Each capture takes about 5 minutes for iOS and 5 to 10 minutes for Android (the emulator-cold-boot step is the bulk of the difference). The resulting PNGs land back on the dashboard. Setup is the same for both platforms. Once you've configured the key, the sign-in lambda, and the paths, you can capture for either platform with one click each. ## Setup Open the Screenshots page in your Ruby Native dashboard and follow the three-step wizard. The summary: ### 1. Generate the key Click **Generate key**. The plaintext is shown once. Copy it immediately, Ruby Native stores only an encrypted copy and won't display it again. If you lose it, regenerate it (which invalidates the prior one). ### 2. Configure your Rails app Add the key to your Rails credentials: ```sh bin/rails credentials:edit ``` ```yaml ruby_native: screenshot_key: ``` In `config/initializers/ruby_native.rb`, register the screenshot key and a sign-in lambda. The same lambda handles both iOS and Android, the bridge hits the same `/native/screenshots/session` endpoint from either platform. Your lambda needs to establish a session the same way a real login does. The simplest rule: do whatever your `SessionsController#create` does after the password checks out. If your auth just sets a cookie, set the cookie. If it creates a session record, create the record too. Setting a cookie alone isn't always enough. `helper` exposes `cookies`, `request`, and `session` as public accessors. Methods your `ApplicationController` mixes in (like Devise's `sign_in` helper) aren't available, so manipulate the cookie jar, session, or warden proxy directly. **Cookie-based auth.** Devise, Clearance, or a hand-rolled encrypted cookie. Signing in is one line: ```ruby RubyNative.configure do |c| c.screenshot_key = Rails.application.credentials.ruby_native.screenshot_key c.screenshot_sign_in = ->(helper) { user = User.find_by!(email: "screenshots@example.com") # Pick the line that matches your auth library: # Devise: helper.request.env["warden"].set_user(user) # Clearance: helper.cookies[:remember_token] = user.remember_token # Custom: helper.cookies.permanent.encrypted[:user_id] = user.id } end ``` **Session-record auth.** Rails 8's built-in `authentication` generator and most roll-your-own systems authenticate by looking up a `Session` record from the cookie. A cookie alone won't sign you in here, the record it points to has to exist. Create it, then set the cookie: ```ruby RubyNative.configure do |c| c.screenshot_key = Rails.application.credentials.ruby_native.screenshot_key c.screenshot_sign_in = ->(helper) { user = User.find_by!(email: "screenshots@example.com") session = user.sessions.create!( user_agent: helper.request.user_agent, ip_address: helper.request.remote_ip ) helper.cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } } end ``` ### 3. Pick paths to capture In the dashboard, list the paths you want to screenshot, one per line: ``` /dashboard /projects /settings ``` The same paths are used for both platforms. Each store has its own minimum: | Store | Minimum | Maximum | Capture resolution | |---|---|---|---| | App Store | 3 | 10 | 1320 × 2868 (6.9" iPhone) | | Play Store | 2 | 8 | 1080 × 2400 (Pixel 6) | Pick at least 3 to satisfy both stores. ## Tips for good screenshots - **Designate a screenshot user with realistic data.** Empty states make a bad first impression. Seed the user with several projects, completed onboarding, etc. - **Use the screenshot session cookie for determinism.** Wrap relative timestamps, push banners, A/B variants, and analytics calls in `if ruby_native_screenshot_session?` so every capture renders identically. - **Lead with your best screen.** The first screenshot appears in store search results. Pick the one that best communicates what your app does. - **Keep it focused.** A few great screenshots that tell a story are better than 10 mediocre ones. ## Risks and mitigations The endpoint at `/native/screenshots/session` is permanent and hittable from anywhere. The whole security model rests on three things: 1. **The screenshot key is in your Rails credentials, not in source.** Without the key, every request to the endpoint gets a 401. The key is sent via URL parameter on the device's first navigation; the gem auto-filters it from `Rails.application.config.filter_parameters`, so it won't appear in your access logs as anything other than `[FILTERED]`. 2. **You designate which user gets signed in.** Use a sandboxed account, not an admin. The sign-in lambda runs in your Rails process; the gem can't see or modify it. If an attacker gets the key, the worst they can do is sign in as the screenshot user. 3. **Rotation is one click.** "Regenerate" on the dashboard invalidates the old key. Update your Rails credentials and redeploy and the leaked key is dead. What this is *not*: a tunneled, ephemeral, or short-lived auth flow. It's a long-lived shared secret. If you need stricter control (audit log, per-capture token, IP allowlist on the endpoint), open a support ticket and we'll discuss. Most apps don't need it. ## Troubleshooting **Capture finishes but no images uploaded.** The device timed out trying to authenticate. Check the workflow run logs, usually the auth path returned 401 because the dashboard's stored key doesn't match what's in your Rails credentials. Regenerate from the dashboard, update credentials, redeploy. **Pages render but show empty / signed-out content.** Two common causes. Either your `screenshot_sign_in` lambda signed in a real user who happens to have no data (add seed data or designate a different user), or the lambda set a cookie but your auth resolves the current user from a session record it never created. For the second case, mirror your real `SessionsController#create`: create the record, then set the cookie to point at it. See the session-record example in [setup](#2-configure-your-rails-app). **Pages flash with skeleton loaders or missing content (iOS).** The bridge fires the "ready" signal as soon as page chrome enumerates, but customer-side hydration (Stimulus, Inertia, XHR) may continue after that. Wrap deterministic hooks in `ruby_native_screenshot_session?` or contact support if your app needs an explicit `RubyNative.markReady()` JS hook. **Capture hangs partway through (Android).** The emulator may have hit a system UI ANR or wedged on a path that never fires the ready signal. The capture run is time-boxed per path, so a hang on one path skips the rest. Try removing or splitting the offending path. ## Next steps Once your screenshots are captured, fill out the store's data declarations before submitting: - iOS: [App privacy](/docs/ios/go-live/app-privacy) - Android: [Data safety](/docs/android/go-live/data-safety) -------------------------------------------------------------------------------- Page: CLI URL: https://rubynative.com/docs/cli -------------------------------------------------------------------------------- # CLI The Ruby Native CLI lets you deploy builds and manage your app from the terminal. ## Authentication Log in with your Ruby Native account: ```sh bundle exec ruby_native login ``` This opens your browser, authorizes the CLI, and stores a token locally. ### CI environments Set the `RUBY_NATIVE_TOKEN` environment variable instead of using `ruby_native login`. Generate a token by running `ruby_native login` locally, then copy it from `~/.ruby_native/credentials`. ```yaml # GitHub Actions example env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` ## Deploy Build your app: ```sh bundle exec ruby_native deploy ``` This builds every platform you have configured. If only iOS is set up, only iOS builds; adding Google Play later means the same command starts building both, with nothing to change in your pipeline. The CLI queues the builds, then waits for all of them. It exits non-zero if any platform fails, so a broken Android build cannot pass behind a successful iOS one. Press Ctrl+C to stop waiting without cancelling anything. ### Building one platform ```sh bundle exec ruby_native deploy --ios bundle exec ruby_native deploy --android ``` `--platform=ios`, `--platform=android`, and `--platform=all` work too. ### Auto-deploy in CI Add `--if-needed` to only build when the gem version has changed since your last successful build: ```sh bundle exec ruby_native deploy --if-needed ``` This compares `RubyNative::VERSION` in your bundle against the gem version from the most recent build of each platform you have configured. When they all match, the deploy is skipped (exit code 0). If any platform is behind, or has never built, a build is triggered. A typical CI setup: ```yaml # .github/workflows/deploy.yml - name: Deploy to Ruby Native run: bundle exec ruby_native deploy --if-needed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` This way every Rails deploy checks if a native rebuild is needed, but only triggers one when the gem actually changed. The command triggers the build and exits immediately without waiting for it to finish, so it won't hold up your CI pipeline. If a build fails for any reason, you'll get an email with the error details. ### App linking The first time you run `deploy`, the CLI asks which app to build (if your account has more than one). It stores the selection as `ruby_native.app_id` in `config/ruby_native.yml`. ## Check Read every view in your app and report signals that will not work: ```sh bundle exec ruby_native check ``` Unknown signals are ignored in the app, mistyped ones render fine but silently fail. `check` finds those before a build, along with duplicated signals and version mismatches. See [Debugging](/docs/debugging) for what it catches and how to read the output. Exits 0 when everything passes and 1 on an error, so it drops straight into CI. | Option | Description | |---|---| | `--deployed` | Also compare against the build your users have, not just the gem in your Gemfile. Needs you to be logged in. | | `--paths=app/views,app/components` | Directories to scan. Defaults to `app/views`. | `check` needs the `herb` gem, which Rails 8.2 and later already include through Action View. On earlier versions, add `gem "herb"` to your Gemfile. ### Deploy runs it for you `deploy` runs the same checks before triggering a build and stops if it finds a problem, so a broken signal never costs you a round trip through TestFlight. Skip it with `--skip-check`: ```sh bundle exec ruby_native deploy --skip-check ``` If the `herb` gem is not installed, `deploy` skips the check and builds as normal. ## Preview Start a Cloudflare Tunnel to your local Rails server and print a QR code to scan with the Ruby Native app: ```sh bundle exec ruby_native preview ``` Two options: | Option | Description | |---|---| | `--port 4000` | The local port your Rails server is on. Defaults to `PORT` when set, then 3000, the same way `rails server` picks its port. | | `--url https://staging.example.com` | Skip the tunnel and point the QR code at a URL you already host. | ## Other commands ```sh bundle exec ruby_native logout # remove stored credentials ``` App Store and Play screenshots are captured by rubynative.com against your deployed site, not from the CLI. See the [screenshots guide](/docs/screenshots). -------------------------------------------------------------------------------- Page: Advanced Mode URL: https://rubynative.com/docs/advanced-mode -------------------------------------------------------------------------------- # Advanced Mode Advanced Mode replaces web navigation with native screen transitions. Pages push and pop with the same animations users expect from any native app, complete with a native navigation bar, back button, and swipe-to-go-back gesture. It builds on Normal Mode, so everything you already have keeps working. > **Requires Turbo Drive.** Advanced Mode drives native navigation with Turbo, so every page must load `turbo.js`. New Rails apps include and enable it by default through `turbo-rails`. ## What you get - **Native screen transitions** with push/pop animations and swipe-to-go-back - **Native navigation bar** with a system back button on every pushed screen - **All Normal Mode signal helpers** keep working unchanged, from tabs and navbar buttons to toasts, menus, and the floating action button Going Advanced means hiding your web navbar and letting the native navigation bar handle those actions instead. ## Setup Set your app mode to `advanced` in `config/ruby_native.yml`: ```yaml app: mode: advanced ``` Hide your web navbar for native users so the native navigation bar takes over: ```erb <%= render "navbar" unless native_app? %> ``` That's it. No additional JavaScript dependencies, no Stimulus setup, no separate imports. The same `native_*` helpers you use in Normal Mode drive both the web and native experiences. A page that doesn't load Turbo shows a configuration error instead of rendering, so make sure no layout or page opts out. ## Clear the native navigation bar The native navigation bar in Advanced Mode is translucent, so once you hide your web navbar your page content scrolls underneath it. Add the `native-inset-top` class to the element that should start below the bar, usually your main content wrapper: ```erb
<%= yield %>
``` The class ships in the gem stylesheet, so make sure your layout includes it: ```erb <%= stylesheet_link_tag :ruby_native %> ``` The inset is exactly the height of the system area above your content, so you never guess a value. On iOS it resolves to `env(safe-area-inset-top)`, which spans the status bar and the navigation bar together, so one class clears both. On Android, where the WebView doesn't populate `env(safe-area-inset-*)` from system bars, the native shell measures the real bar height and feeds it in, so you get the same result without any per-device tweaking. Two companion classes cover the rest of the screen: - `native-inset-bottom` clears the home indicator or gesture bar at the bottom. - `native-inset` clears both the top and bottom at once. ## Helpers Advanced Mode uses the same signal helpers as Normal Mode. See the individual guides for the full API: - **Tabs**: [`native_tabs_tag`](/docs/tabs) - **Navigation bar**: [`native_navbar_tag`](/docs/navbar) with nested `button`, `menu`, `segment`, `submit_button`, and `share_button` - **Forms**: [`native_form_tag`](/docs/forms) to mark pages as forms (so back navigation skips them) - **Keyboard**: [`native_keyboard_tag`](/docs/keyboard) to hide the iOS keyboard toolbar - **Authentication**: [`native_identity_tag`](/docs/authentication) to reset the app on sign-out - **Push notifications**: [`native_push_tag`](/docs/push-notifications) - **Badges**: [`native_badge_tag`](/docs/badges) - **Toasts**: [`native_toast_tag`](/docs/toasts) - **Menus**: [`native_menu_tag`](/docs/menus) anchored to the page's own content - **Floating action button**: [`native_fab_tag`](/docs/fab) - **Haptics**: [`native_haptic_data`](/docs/haptics) - **Root pages**: [`native_presentation_tag`](/docs/root-pages) - **Barcode scanner**: [`native_scan_button_tag`](/docs/barcode-scanner) - **In-app review**: [`native_review_tag`](/docs/review) - **Overscroll colors**: [`native_overscroll_tag`](/docs/appearance#overscroll-colors) ## Migrating from Normal to Advanced If your app already uses Normal Mode, flipping to Advanced is a two-line change: 1. Set `mode: advanced` in your `config/ruby_native.yml` (see [setup](#setup) above). 2. Hide your web navbar with `native_app?` so the native navigation bar takes over. That's it. Every `native_*` helper you're already using keeps working identically in Advanced Mode: tabs, navbar, buttons, menus, submit buttons, badges, haptics. The difference is that new pages now push onto a native navigation stack instead of loading in place. -------------------------------------------------------------------------------- Page: iOS URL: https://rubynative.com/docs/ios -------------------------------------------------------------------------------- # iOS Step-by-step guides for configuring your Apple Developer account, shipping to TestFlight, and submitting to the App Store. ## Setup - [Apple Developer Program](/docs/ios/enrollment) - enroll in the program and set up your account - [Developer credentials](/docs/ios/credentials) - Team ID, App Store Connect API key, and .p8 file - [App Store Connect](/docs/ios/app-store-connect) - create your app record so Ruby Native can upload builds - [Push notifications](/docs/ios/push-notifications) - configure APNs credentials in your Rails app ## Go live on the App Store Start with the [checklist](/docs/ios/go-live), then work through the details: - [Payments](/docs/ios/go-live/payments) - in-app purchase or the companion path, and Apple's cuts - [In-app purchase setup](/docs/ios/go-live/in-app-purchase-setup) - create your subscription products - [App privacy](/docs/ios/go-live/app-privacy) - privacy nutrition labels and what to declare - [App Store submission](/docs/ios/go-live/submission) - fill out your listing and submit for review - [App Store review](/docs/ios/go-live/app-store-review) - why Ruby Native apps get approved and tips for a smooth review -------------------------------------------------------------------------------- Page: Apple Developer Program URL: https://rubynative.com/docs/ios/enrollment -------------------------------------------------------------------------------- # Apple Developer Program You need an Apple Developer Program membership to distribute apps through TestFlight and the App Store. Ruby Native uses your membership to sign builds and upload them to Apple. ## What you need before enrolling - **An Apple ID.** If you don't have one, create one at [account.apple.com](https://account.apple.com). You don't need a Mac or an iPhone to create an Apple ID. - **Two-factor authentication** enabled on your Apple ID. Apple requires this for developer accounts. ## Enroll 1. Go to [developer.apple.com/programs/enroll](https://developer.apple.com/programs/enroll). 2. Sign in with your Apple ID. 3. Follow the steps to complete enrollment. You'll need to agree to the Apple Developer Agreement and pay the $99/year fee. Apple reviews enrollment applications before granting access. This typically takes **24 to 48 hours**, though it can occasionally take longer. You'll receive an email when your account is ready. ## Individual vs. organization Apple offers two enrollment types: - **Individual** ($99/year): the app is published under your personal name. Fastest to set up. No additional paperwork. - **Organization** ($99/year): the app is published under your company name. Requires a [D-U-N-S number](https://developer.apple.com/support/D-U-N-S/), which is free but can take a few days to obtain. If you're a solo developer or just getting started, individual is fine. You can transfer apps to an organization account later. ## After enrollment Once Apple approves your enrollment, you'll have access to: - [App Store Connect](https://appstoreconnect.apple.com) for managing apps, TestFlight builds, and App Store listings - The [Developer portal](https://developer.apple.com/account) for managing certificates, keys, and identifiers Your next step is to [set up your developer credentials](/docs/ios/credentials) so Ruby Native can build and upload on your behalf. -------------------------------------------------------------------------------- Page: Apple Developer credentials URL: https://rubynative.com/docs/ios/credentials -------------------------------------------------------------------------------- # Apple Developer credentials Ruby Native needs three things from your Apple Developer account to sign your app and upload it to TestFlight: your **Team ID**, an **App Store Connect API key** (with its key ID and issuer ID), and the key's **.p8 private key file**. ## Team ID Your Team ID identifies your Apple Developer account. You'll use the same one for all your apps. 1. Sign in to [developer.apple.com/account](https://developer.apple.com/account). 2. Scroll down to the **Membership details** section. 3. Your **Team ID** is a 10-character alphanumeric string (e.g., ABCDE12345). Copy it. ![Membership details section with Team ID highlighted](/docs/ios/team-id.png) If you're on a team with multiple members, any member can view the Team ID. It's not a secret, just an identifier. ## App Store Connect API key The API key lets Ruby Native upload builds to TestFlight on your behalf. You create it in App Store Connect, not the Developer portal. 1. Go to [App Store Connect](https://appstoreconnect.apple.com) and sign in. 2. Click **Users and Access** in the top navigation. 3. Click the **Integrations** tab. 4. In the left sidebar, click **App Store Connect API**. 5. Under **Team Keys**, click the **+** button to generate a new key. 6. Enter a name you'll recognize later, like "Ruby Native". 7. For access, select **Admin**. Apple only lets Admin keys sign apps with its cloud-managed certificates and register bundle IDs, and every build does both. A lesser role could upload builds but couldn't sign them. See [What Ruby Native does with this key](#what-ruby-native-does-with-this-key). 8. Click **Generate**. Make sure it's a **Team Key**, the kind under Users and Access. An **Individual Key**, generated from your own user profile, can't access provisioning, so it can't sign your app even with the Admin role. ![Generate API Key dialog with Admin role selected](/docs/ios/generate-api-key.png) After generating the key, you'll see it listed in the table. Three values come from this page: - **Key ID**: shown in the table next to your key name. A short alphanumeric string like ABC123DEFG. - **Issuer ID**: shown at the top of the page, above the key table. A UUID like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. This is the same for all keys under your account. - **.p8 file**: click **Download** next to the key. This downloads a file named `AuthKey_XXXXXXXX.p8`. ![API keys table showing Key ID, Issuer ID, and Download button](/docs/ios/api-keys-table.png) **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to revoke the key and create a new one. Store it somewhere safe. ## Can I reuse an existing key? Yes, as long as it's a **Team Key** with the **Admin** role. An Individual Key can't sign apps, even as Admin. To reuse a key, you'll need the key ID, issuer ID, and the .p8 file you downloaded when you first created it. ## What Ruby Native does with this key Every call Ruby Native makes with your key falls into one of these buckets: - **Sign and build.** Each build runs `xcodebuild` with your key so Xcode can use Apple's cloud-managed distribution certificate and create or reuse a provisioning profile for your bundle ID. Nothing is signed with a certificate we hold. - **Register your app ID.** Register the bundle ID and turn on the capabilities your config asks for: push notifications, in-app purchases, and associated domains. - **Distribute to TestFlight.** Upload the build, keep a "Ruby Native" internal TestFlight group, add each build to it, and add you as a tester. - **Watch for changes.** Register a webhook on your app so the dashboard knows when a build finishes processing and when a version changes state in App Store review. - **Optional features.** Read your subscription products if you use in-app purchases. If you use automated screenshots, upload them to your listing, creating a new App Store version when none is editable and replacing only the screenshot sets it fills. Outside of those, Ruby Native never creates apps or changes settings it didn't create. It adds to your account and leaves the rest alone. ### Why Admin Apple ties two things Ruby Native needs on every build to the Admin role: cloud signing and registering bundle IDs. Apple offers no way to grant either to an App Manager or Developer key, and Team Keys can't be limited to a single app. Uploading builds and managing TestFlight would work with a lesser role, but signing wouldn't, so the build would fail before it reached TestFlight. ### How it's stored The key ID, issuer ID, and .p8 are encrypted at rest with Rails encrypted attributes. They're decrypted only to sign an API request or to hand to the build runner, where they're masked in logs and destroyed with the runner when the build ends. You can revoke the key at any time in Users and Access. Revoking stops future builds; apps already on TestFlight or in the App Store are unaffected. The [security page](/security) covers everything else Ruby Native holds and how to take it back. ## What if I don't have an Apple Developer account? You need an Apple Developer Program membership ($99/year) to distribute apps through TestFlight and the App Store. Ruby Native can't build or upload without one. See the [enrollment guide](/docs/ios/enrollment) for step-by-step instructions. -------------------------------------------------------------------------------- Page: Create your app in App Store Connect URL: https://rubynative.com/docs/ios/app-store-connect -------------------------------------------------------------------------------- # Create your app in App Store Connect App Store Connect is where Apple manages your app's listing, TestFlight builds, and App Store submissions. Before Ruby Native can upload a build, your app needs to exist there. Ruby Native registers your bundle ID automatically, but Apple doesn't allow creating app records through their API. This is a one-time manual step that takes about two minutes. ## Before you start Make sure you have: - An active [Apple Developer Program membership](/docs/ios/enrollment) - Your [developer credentials](/docs/ios/credentials) entered in Ruby Native (this registers your bundle ID) ## Create the app 1. Go to [appstoreconnect.apple.com/apps](https://appstoreconnect.apple.com/apps) and sign in. 2. Click the **+** button in the top left and select **New App**. 3. Fill in the form: | Field | What to enter | |---|---| | **Platforms** | Check **iOS** | | **Name** | Your app's display name (e.g., "My App"). This is what users see on the App Store. You can change it later. | | **Primary language** | Your preferred language (e.g., English (U.S.)) | | **Bundle ID** | Select your bundle ID from the dropdown. Ruby Native registered it when you entered your credentials. | | **SKU** | Any unique string. Your bundle ID works fine here. | | **User Access** | Full Access (unless you have specific team access needs) | 4. Click **Create**. You'll land on your app's page in App Store Connect. That's it. Head back to Ruby Native and click **Verify app** to continue. ## Common issues ### Bundle ID not showing in the dropdown The dropdown only shows registered bundle IDs. If yours isn't listed: - **Wait a moment.** It can take a minute after Ruby Native registers the bundle ID for it to appear in App Store Connect. - **Check your credentials.** Make sure the API key you entered in Ruby Native belongs to the same Apple Developer account you're signed into in App Store Connect. - **Reload the page.** Close the "New App" dialog, refresh, and try again. ### App name is taken Apple requires unique app names across the entire App Store. If someone else is already using your preferred name, you'll need to pick a different one. This only affects the display name. Your bundle ID stays the same. Try adding a descriptor (e.g., "Acme Tasks" instead of "Tasks") or use your company name as a prefix. ### "You don't have required role" You need the **Account Holder** or **Admin** role in App Store Connect to create apps. If you're on a team, ask someone with the right role to either create the app or upgrade your access in Users and Access. -------------------------------------------------------------------------------- Page: Push notifications URL: https://rubynative.com/docs/ios/push-notifications -------------------------------------------------------------------------------- # Push notifications Configure APNs credentials so your Rails server can send push notifications to iOS devices. If you haven't yet, set up the [Rails side first](/docs/push-notifications). ## Create an APNs key To send push notifications, you need an **APNs key** from your Apple Developer account. This is a separate key from the App Store Connect API key used for builds. 1. Sign in to [developer.apple.com/account](https://developer.apple.com/account). 2. In the middle column, click **Keys** (under Certificates, Identifiers & Profiles). 3. Click the **+** button to register a new key. 4. Enter a name you'll recognize, like "Ruby Native Push". 5. Check the **Apple Push Notifications service (APNs)** checkbox. Leave everything else unchecked. ![Register a New Key page with APNs checkbox checked](/docs/ios/register-key.png) 6. Click **Configure** next to the checkbox. 7. Select **Sandbox & Production** for Environment. 8. Ensure Key Restriction is set to **Team Scoped (All Topics)**. 9. Click **Save**, then **Continue**, then **Register**. ![Configure Key page with Sandbox & Production and Team Scoped selected](/docs/ios/configure-key.png) After registering, you'll see a confirmation page with your **Key ID** (a 10-character string) and a **Download** button for the `.p8` file. ![Download Your Key page showing Key ID and Download button](/docs/ios/download-key.png) **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to revoke the key and create a new one. A single APNs key works for every app under your Apple Developer team. You don't need to create a new one per app. APNs keys also never expire. ## Configure APNs credentials The installer creates `config/push.yml`. Add your Apple credentials so the gem can send notifications through APNs: ```yaml # config/push.yml shared: apple: key_id: <%= Rails.application.credentials.dig(:apns, :key_id) %> team_id: <%= Rails.application.credentials.dig(:apns, :team_id) %> topic: com.yourcompany.yourapp encryption_key: <%= Rails.application.credentials.dig(:apns, :encryption_key)&.dump %> ``` - **key_id**: from the APNs key you just created. - **encryption_key**: the full contents of the `.p8` file. - **team_id**: your [Team ID](/docs/ios/credentials#team-id). - **topic**: your app's bundle ID. You can find this on your app's settings page in the Ruby Native dashboard. ![Bundle ID shown on the app page in the Ruby Native dashboard](/docs/ios/bundle-id-dashboard.png) -------------------------------------------------------------------------------- Page: Go live on the App Store URL: https://rubynative.com/docs/ios/go-live -------------------------------------------------------------------------------- # Go live on the App Store A pre-flight checklist for getting your app approved on the App Store. Ruby Native handles the builds, signing, and uploads; this page is everything left, with links to the details. Shipping Android too? Nothing transfers automatically between the stores, so Google Play has [its own checklist](/docs/android/go-live). ## What Ruby Native already handles - Code signing and delivery to TestFlight - App icons and launch screens in the correct formats - [Permission strings](/docs/permissions) generated from your dashboard settings - Export compliance, so Apple won't ask about encryption on every submission - [Screenshots](/docs/screenshots) captured against your deployed site ## The checklist Product decisions first, then metadata. The [submission guide](/docs/ios/go-live/submission) covers the mechanics of each field. - **Decide how you'll handle payments.** If you sell subscriptions or digital goods, read [payments](/docs/ios/go-live/payments) before building anything; most SaaS apps need less than they think. - **Sign in with Apple, if it applies.** Only required when you offer social logins like Google or GitHub. [Details and exemptions](/docs/oauth). - **Account deletion.** If users can create an account, deletion must start in the app. Your web flow works; link it from a settings screen. [More](/docs/ios/go-live/app-store-review). - **Demo account.** A production login for reviewers with realistic seeded data, entered in App Review Information. [More](/docs/ios/go-live/submission). - **App name, subtitle, keywords.** 30, 30, and 100 characters of search signal. [Field-by-field advice](/docs/ios/go-live/submission). - **Screenshots.** Five or so at the 6.9 inch size covers every iPhone. [More](/docs/ios/go-live/submission). - **App privacy questionnaire.** What you collect, whether it's linked to identity, what reaches third parties. [Walkthrough](/docs/ios/go-live/app-privacy). - **Age rating questionnaire.** Most business apps land at 4+. [More](/docs/ios/go-live/submission). - **Privacy policy, twice.** A URL in App Store Connect and a link somewhere inside the app. - **Content rights and EULA.** One yes/no declaration; skip the custom EULA unless you have your own agreement. Then [submit as-is](/docs/ios/go-live/submission). If Apple pushes back it's almost always payments or login services, and a Resolution Center reply citing the guideline text resolves more rejections than you'd expect. Plan for one or two cycles, each usually under 24 hours. > Want a second set of eyes on your launch? Turnkey includes hands-on submission help, from your listing through review. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: Payments on the App Store URL: https://rubynative.com/docs/ios/go-live/payments -------------------------------------------------------------------------------- # Payments on the App Store Whether your app needs in-app purchases is the most important decision to make before submission. Here's where things stand as of August 2026. Shipping Android too? Google has [its own rules](/docs/android/go-live/payments). ## The baseline rule If you sell digital goods or subscriptions inside the app, Apple requires [in-app purchase](/docs/in-app-purchases) (guideline 3.1.1). The cuts: 30% standard, 15% in the [Small Business Program](https://developer.apple.com/app-store/small-business-program/) (under $1M/year), and 15% on subscriptions after a subscriber's first year. Physical goods, services consumed outside the app, and person-to-person payments are exempt. ## The companion app path Most SaaS apps don't need any of that. Apple's guideline 3.1.3(f) lets a free app act as a stand-alone companion to a paid web tool, with no in-app purchases, provided there's no purchasing inside the app and no calls to action to purchase elsewhere. Apple's own examples include email services, cloud storage, and web hosting. In practice: sign-ups and billing live on your website, the app serves existing customers, and nothing in the app mentions upgrading or pricing. This is the simplest path to approval and the one most Ruby Native apps take. Two exceptions worth knowing. If users can buy things on your site and use them in the app, guideline 3.1.3(b) says those purchases must also be offered as in-app purchases, so keep purchasing and consumption clearly on the web side. And 3.1.3(c) exempts apps sold only to organizations for their employees, but offering any individual B2C accounts disqualifies you. ## The US carve-out Since mid-2025, following the Epic v. Apple ruling, apps on the United States storefront may include buttons and links to purchase on the web, with no in-app purchase and currently no commission to Apple. The ruling survived appeal, though the case is still moving and Apple may eventually be allowed to charge a cost-based fee on link-outs. The catch: this only applies to the US storefront. An upgrade link shown to every user violates the rules everywhere else, so it has to be limited to US users. The storefront is the Apple ID's store region, which only the device knows: your server can't detect it, and Ruby Native doesn't surface it to your Rails app yet. Until it does, treat US link-outs as a carve-out to watch, not something to build on. ## What to do - **You sell nothing digital, or only to businesses on the web:** ship the companion path. No purchases, no upgrade CTAs, nothing to build. - **You want in-app conversion:** build [in-app purchases](/docs/in-app-purchases) and accept the cut, or wait for the link-out landscape to settle. - **You're not sure:** ship the companion path first. Adding purchases later is a normal update; removing them after a rejection fight is not. This area changes quarter to quarter. Check the current [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/#payments) at submission time. > Not sure which path fits? Turnkey includes help deciding, and handling Apple if they push back. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: In-app purchase setup URL: https://rubynative.com/docs/ios/go-live/in-app-purchase-setup -------------------------------------------------------------------------------- # In-app purchase setup Create your subscription products in App Store Connect so users can purchase them in your app. ## Create a subscription group Subscription groups let users upgrade or downgrade between plans without being charged twice. Each app needs one group. 1. Open your app in [App Store Connect](https://appstoreconnect.apple.com). 2. Go to **Subscriptions** under In-App Purchases in the sidebar. 3. Click the **+** next to **Subscription Groups**. 4. Enter a reference name matching your app name (e.g., "My App"). ![Creating a new subscription group](/docs/iap/asc-create-group.png) ## Add a subscription 1. Within your subscription group, click **Create** next to **Subscriptions**. 2. Enter a **Reference Name** (internal only, e.g., "Pro Annual"). 3. Enter a **Product ID** using reverse domain notation: `com.yourapp.pro.annual`. ![Creating a new subscription with product ID](/docs/iap/asc-create-subscription.png) 4. Click **Create**. 5. Set the **Subscription Duration** (1 week, 1 month, 1 year, etc.). 6. Under **Subscription Prices**, click **Add Subscription Price** and choose your price. Repeat for each plan (e.g., `com.yourapp.pro.monthly` and `com.yourapp.pro.annual`). ## Submit for review Your first subscription must be submitted with a new app version. After that, additional subscriptions can be submitted directly from the Subscriptions page. 1. Go to your app version page in App Store Connect. 2. Scroll to **In-App Purchases and Subscriptions** and add your subscription. 3. Submit the version for review. A review screenshot is required. It must be a JPEG at one of Apple's accepted device dimensions. PNGs may fail with a misleading "dimensions are wrong" error due to file size. | Device | Dimensions | |--------|------------| | iPhone 16 Pro Max (6.9") | 1320 x 2868 | | iPhone 15 Pro Max (6.7") | 1290 x 2796 | | iPhone 14 Pro Max (6.5") | 1284 x 2778 | Ruby Native captures screenshots on an iPhone 16 Pro Max simulator. If uploading manually, take a simulator screenshot and convert to JPEG before uploading. If you're using Ruby Native's screenshot pipeline, the review screenshot is uploaded automatically when you publish your screenshots. ## Sync your products After creating your products, go to your app's **Purchases** tab in the Ruby Native dashboard and click **Sync**. This pulls your product IDs so you can copy them into your paywall HTML. -------------------------------------------------------------------------------- Page: App privacy URL: https://rubynative.com/docs/ios/go-live/app-privacy -------------------------------------------------------------------------------- # App privacy Apple requires every app to disclose what data it collects through privacy "nutrition labels." You fill these out in App Store Connect before your first submission. ## Where to find it 1. Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com) and open your app. 2. Click **App Privacy** in the left sidebar. 3. Click **Get Started** or **Edit** next to Data Collection. Apple will walk you through a series of questions about the data your app collects. Answer based on what your Rails app actually stores, not what iOS frameworks are capable of. ## Data types for a typical Rails app Most Rails apps with user accounts collect a few standard data types. Here's what to declare and how to categorize each one. ### Contact info (email address) Almost every Rails app collects an email address for authentication. - **Data type:** Contact Info > Email Address - **Purpose:** App Functionality - **Linked to identity:** Yes ### Identifiers (user ID) If your app has user accounts, you store some form of user ID. - **Data type:** Identifiers > User ID - **Purpose:** App Functionality - **Linked to identity:** Yes ### Name (if collected) Only declare this if your app collects the user's name during signup or in a profile. - **Data type:** Contact Info > Name - **Purpose:** App Functionality - **Linked to identity:** Yes ### Usage data (analytics) If your app records how users interact with it, declare it. This includes server-side analytics like page views, feature usage, or event tracking. See the next section for details. - **Data type:** Usage Data > Product Interaction - **Purpose:** Analytics - **Linked to identity:** Depends on your setup (see below) ### What most Rails apps don't need to declare Unless your app specifically collects these, skip them: - Location data - Health and fitness data - Financial information - Browsing history - Contacts or address book - Photos or videos (unless your app uploads them) ## Analytics and tracking Apple's definitions here are specific and worth understanding. **"Collect" means data transmitted off the device and stored.** Server-side analytics count if the data is retained, which it almost always is. If your Rails app logs page views, tracks events, or stores any usage metrics, you need to declare "Product Interaction" under Usage Data. **"Tracking" is narrower than you'd think.** Apple defines tracking as linking user or device data with third-party data for targeted advertising or sharing with data brokers. Standard first-party analytics, where you record how users interact with your own app, are categorized as "analytics," not "tracking." **Ruby Native's launch ping** sends the bundle ID, the platform, and a random identifier generated once per install to rubynative.com each time the app launches. It powers the active devices count on your dashboard. This needs to be declared as Usage Data > Product Interaction, with the purpose set to Analytics and not linked to identity. To skip it, turn reporting off with `analytics: false` in `config/ruby_native.yml`; see [usage reporting](/docs/setup#usage-reporting). If you use third-party analytics like Google Analytics, Mixpanel, or Amplitude, check their documentation for additional data types you may need to declare. ## Privacy policy Every app needs a privacy policy URL in App Store Connect. Add it under **App Information > Privacy Policy URL**. Your existing website privacy policy usually works. Just make sure it mentions the iOS app and covers push notification device tokens if you use them. Your next step is to [submit your app](/docs/ios/go-live/submission). > Need help getting to the App Store? Turnkey includes hands-on help with your listing, privacy answers, and review. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: App Store submission URL: https://rubynative.com/docs/ios/go-live/submission -------------------------------------------------------------------------------- # App Store submission Your build is on TestFlight, your screenshots are uploaded, and your privacy questionnaire is done. Time to submit. ## Before you start Make sure you've completed each of these: - [Payments](/docs/ios/go-live/payments) - in-app purchases built, or nothing to do on the companion path - [Screenshots](/docs/screenshots) - uploaded to App Store Connect - [App privacy](/docs/ios/go-live/app-privacy) - data collection questionnaire filled out - [App Store review preparation](/docs/ios/go-live/app-store-review) - your app meets Apple's guidelines - A privacy policy URL added to App Store Connect ## Fill out the App Store listing Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com), open your app, and click the version under **iOS App** in the left sidebar. ### App name and subtitle Your app name can be up to 30 characters. The subtitle is another 30 characters and appears below the name in search results. Use both to communicate what your app does. Include relevant keywords where they fit naturally. ### Description You have 4,000 characters, but the first two or three lines matter most. That's what users see before tapping "more." Lead with what the app does and why someone would use it. Don't mention Ruby Native, Rails, or implementation details in your description. Users care about what the app does for them, not how it's built. ### Keywords You get 100 characters of comma-separated keywords. These are hidden from users but used by App Store search. - Don't repeat words from your app name. Apple already indexes those. - Use singular forms ("recipe" not "recipes"). - Don't add spaces after commas. Every character counts. - Think about what your users would search for, not technical terms. - Multi-word phrases match in any combination, so "ruby,on,rails" covers "ruby on rails", "rails", and "ruby". ### Screenshots One is required, up to 10 are allowed, and five or so is the sweet spot. A single set at the 6.9 inch iPhone size covers every device; iPad screenshots are only needed if your app runs on iPad. Consider mocking them up in a design tool so you can use interesting sample data. The drift between the mockup and the real app is worth the pain for assets this visible. The [screenshot tool](/docs/screenshots) captures real screens against your production site as a starting point. ### Promotional text 170 characters that appear above your description. This is the one field you can update without submitting a new version for review. Use it for announcements, seasonal messaging, or highlighting new features. ### Category Pick the category that best describes your app. Common choices for Ruby Native apps: - **Business** - internal tools, B2B apps - **Productivity** - task management, workflow apps - **Lifestyle** - community, events, memberships - **Education** - courses, learning platforms - **Social Networking** - forums, groups, networking - **Utilities** - practical tools You can pick a primary and secondary category. ## Age rating Apple asks a series of questions about your app's content to determine its age rating. The brackets are 4+, 9+, 13+, 16+, and 18+, and the questions cover in-app controls and capabilities alongside content. Answer honestly based on what your app contains. Most standard web apps end up rated 4+. One question to watch: "unrestricted web access" forces a minimum 16+ rating. That means embedded general-purpose browsing, like a built-in browser. An app that renders your own product in web views does not qualify, so answer no unless users can genuinely browse the open web inside your app. ## Select your build Scroll to the **Build** section and click the **+** button. You'll see a list of builds uploaded to TestFlight. Select the one you want to submit. If you don't see your build, make sure it has finished processing in TestFlight. New builds take a few minutes to become available. ## Submit for review 1. Click **Add for Review** in the upper right. 2. Review the summary of everything you're submitting. 3. Click **Submit to App Review**. **Demo account:** if your app requires a login, Apple needs a demo account to test with. Add the credentials in the **App Review Information** section before submitting. This account should have access to the app's core features. ## What to expect Most apps are reviewed within 24 hours. You can check the status in App Store Connect or wait for an email. If Apple has questions or rejects your app, you'll find the details in the **Resolution Center** inside App Store Connect. Common rejection reasons and how to handle them are covered in the [App Store review guide](/docs/ios/go-live/app-store-review). ## After approval Once approved, you choose when to release: - **Manually release** - you click a button when you're ready - **Automatically after approval** - goes live as soon as Apple approves - **On a specific date** - schedule a launch date Your app will be available on the App Store within a few hours of release. The direct link to your listing follows this format: ``` https://apps.apple.com/app/id{your_apple_id} ``` You can find your Apple ID in App Store Connect under **App Information**. > Need help with your submission? Turnkey includes hands-on help with your listing, rejections, and going live. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: App Store review URL: https://rubynative.com/docs/ios/go-live/app-store-review -------------------------------------------------------------------------------- # App Store review Apple reviews every app before it goes live. If your app wraps a website without adding native value, it will be rejected under [guideline 4.2 (Minimum Functionality)](https://developer.apple.com/app-store/review/guidelines/#minimum-functionality). Ruby Native is designed to pass this review. ## Why Ruby Native apps get approved Apple rejects apps that are "repackaged websites." Ruby Native apps are not. Every app includes real native components that Apple expects to see: - **Native tab bar.** Built with first-party iOS APIs, not a web element styled to look native. - **Push notifications.** Real APNs integration, not a web push workaround. - **App icon and launch screen.** Proper Xcode assets, not a web favicon. These features are what separate a native app from a wrapped website in Apple's eyes. Ruby Native includes them by default, configured through YAML. ## Tips for a smooth review Based on shipping 25+ apps through App Store review, here are the most common things to get right. ### Make your app feel complete Apple tests your app as a new user. If reviewers hit a login wall with no way to explore, they may reject for "demo account required." Either provide a demo account in App Store Connect, or make sure unauthenticated users can see enough of the app to understand its value. ### Support account deletion If your app supports account creation, Apple requires you to offer account deletion within the app. This has been enforced since June 2022. Add a "Delete account" option in your settings or profile page that removes the user's account and personal data. Your existing web-based account deletion page works inside the Ruby Native shell. No native-specific code needed. ### Be careful with in-app purchases If your app sells digital goods or subscriptions, Apple requires their in-app purchase system and takes a cut. The [payments guide](/docs/ios/go-live/payments) covers the rules, the current cuts, and the companion-app path most Ruby Native apps take instead. ### Include required legal links Every app needs a privacy policy link in two places: somewhere in your Rails app (a settings or profile page works well) and in the App Store Connect metadata. If you have terms of service, include those too. Apple checks for these during review. ## What Ruby Native handles for you When you use cloud builds, Ruby Native takes care of the technical requirements that commonly trip up first-time submissions: - Proper code signing and provisioning profiles - Required `Info.plist` entries (camera, photo library permissions) - Export compliance flag (`ITSAppUsesNonExemptEncryption` set to `NO`), so Apple won't ask about encryption on each submission - App icon and launch screen assets in the correct formats - A properly structured Xcode archive for App Store submission > Need help with App Store review? Turnkey includes review help: decoding the feedback and getting you approved. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: Android URL: https://rubynative.com/docs/android -------------------------------------------------------------------------------- # Android Step-by-step guides for configuring your Google Play Console account, shipping to internal testing, and promoting to the production Play Store. ## Setup - [Developer account](/docs/android/developer-account) - sign up for the Google Play Console - [App listing](/docs/android/app-listing) - create the Play Console app and confirm Play App Signing - [Invite Ruby Native](/docs/android/invite-publisher) - grant our publisher service account permission to upload builds - [Push notifications](/docs/android/push-notifications) - connect Firebase and send notifications to Android devices - [Internal testing](/docs/android/internal-testing) - add testers and install your first build ## Go live on Google Play Start with the [checklist](/docs/android/go-live), then work through the details: - [Payments](/docs/android/go-live/payments) - Play billing or the companion path, and Google's cuts - [Data safety](/docs/android/go-live/data-safety) - fill out the Data safety form for production - [Play submission](/docs/android/go-live/submission) - promote a build from internal testing to production -------------------------------------------------------------------------------- Page: Google Play Developer account URL: https://rubynative.com/docs/android/developer-account -------------------------------------------------------------------------------- # Google Play Developer account You need a Google Play Developer account to distribute apps through the Play Store. Ruby Native uses your account to host your app listing and accept builds we upload on your behalf. ## Enroll 1. Go to [play.google.com/console/signup](https://play.google.com/console/signup). 2. Sign in with the Google account that should own your developer profile. 3. Follow the steps to complete enrollment. You'll need to agree to the Google Play Developer Distribution Agreement and pay the **$25 one-time** registration fee. Google verifies your identity before granting access. This usually takes a few hours, though it can occasionally take longer. You'll get an email when your account is ready. ## Personal vs. organization Google offers two account types: - **Personal** - the app is published under your name. Fastest to set up. - **Organization** - the app is published under your company name. Requires verifying the organization with Google before your first listing can go live. If you're a solo developer or just getting started, personal is fine. You can transfer apps later. ## After enrollment Once Google approves your account, you'll have access to [Play Console](https://play.google.com/console) for managing apps, internal testing tracks, and store listings. Your next step is to [create your app listing](/docs/android/app-listing) so Ruby Native can upload builds. -------------------------------------------------------------------------------- Page: Create your app in Play Console URL: https://rubynative.com/docs/android/app-listing -------------------------------------------------------------------------------- # Create your app in Play Console Play Console is where Google manages your app's listing, internal testing tracks, and Play Store submissions. Before Ruby Native can upload a build, your app needs to exist there. The Google Play Developer API does not allow programmatic creation of new app listings, so this is a one-time manual step that takes about two minutes. ## Before you start Make sure you have an active [Google Play Developer account](/docs/android/developer-account). You'll also need your app's **Bundle ID** from Ruby Native. Open your app's *Settings* page on rubynative.com and copy the value of the *Bundle ID* field. It looks like `app.example.rubynative`. ## Create the app 1. Go to [play.google.com/console](https://play.google.com/console) and sign in. 2. Click **Create app**. 3. Set the app name, default language, and **App** or **Game** classification. 4. Pick **Free** or **Paid**, accept the declarations, and click **Create app**. 5. When Play asks for the package name, paste the Bundle ID you copied from Ruby Native. If the package name doesn't match, the preflight returns: ``` Your Play Console listing for {package_name} doesn't exist. Create it in Play Console first, then retry. ``` ## Play App Signing Play App Signing is enrolled automatically for every new app listing. There's nothing to accept and no key to manage. Google holds the upload key and signs your AABs at distribution time. If you're migrating an existing signing key from another distribution channel, follow [Google's App Signing docs](https://support.google.com/googleplay/android-developer/answer/9842756) for the import flow. Your next step is to [invite Ruby Native's publisher service account](/docs/android/invite-publisher) so we can upload AABs on your behalf. -------------------------------------------------------------------------------- Page: Invite Ruby Native to Play Console URL: https://rubynative.com/docs/android/invite-publisher -------------------------------------------------------------------------------- # Invite Ruby Native to Play Console Ruby Native uploads your Android builds using a Google Cloud service account created for your app. To grant the build pipeline permission to upload AABs, invite that app's publisher email into your Play Console with **Release manager** permission. We never see or store your Google credentials. The service account only has access to the apps you explicitly invite it to, and every app gets its own. ## Find the publisher email The Play Console access step in the Ruby Native dashboard shows your app's publisher email with a copy button. Use that one: each app has a different email, so don't reuse one from another app. If the step says it couldn't set up your publisher email, reload the page. If that doesn't fix it, email support. ## Invite the service account 1. Open [Play Console](https://play.google.com/console) and select your app. 2. Click the gear icon (top right) and choose **Users and permissions**. 3. Click **Invite new users**. 4. Paste the publisher email from the dashboard panel into **Email address**. 5. Leave **Account permissions** at default. Service accounts inherit per-app permissions only. 6. Click **Add app** and select your app. 7. Grant the **Release manager** role. If you prefer granular permissions, select all of: - Create, edit, and delete draft apps - Release apps to testing tracks - Release apps to production 8. Click **Save changes**, then **Invite user**. Service accounts show as **Active** immediately. There's no email confirmation step. ## What happens if you skip this If this step is skipped or scoped to the wrong app, the preflight returns: ``` Service account doesn't have publisher access for {package_name}. Invite our publisher email to your Play Console with Release manager permission for this app. ``` Repeat the steps above to fix it, then retry the build. Your next step is to [trigger your first build](/docs/android/internal-testing) and get it onto the Internal Testing track. -------------------------------------------------------------------------------- Page: Push notifications URL: https://rubynative.com/docs/android/push-notifications -------------------------------------------------------------------------------- # Push notifications Configure FCM credentials so your Rails server can send push notifications to Android devices. If you haven't yet, set up the [Rails side first](/docs/push-notifications). Android delivery runs through Firebase Cloud Messaging (FCM). The `action_push_native` device model stores Android tokens with `platform: "google"`. ## Create a Firebase project Unlike iOS, Android push needs a Firebase project. Its config is compiled into your app, so this is a one-time step. 1. Create a project at [console.firebase.google.com](https://console.firebase.google.com). 2. Add an Android app using the package name shown on the **Push** tab of your app in the Ruby Native dashboard. 3. Download the `google-services.json` file. Firebase then prompts you to add the SDK and verify the install. Skip those steps. Ruby Native wires up Firebase when it builds your app. 4. Upload `google-services.json` on the **Push** tab in the Ruby Native dashboard. That covers the build-time half: the config baked into the app so it can receive notifications. ## Create an FCM service account key To *send* notifications, your server needs a service account key from the same Firebase project. 1. In the Firebase console, open your project and click the gear icon, then **Project settings**. 2. Open the **Service accounts** tab. 3. Click **Generate new private key**, then confirm. A JSON file downloads. This JSON is a credential. Treat it like a password and do not commit it to your repo. ### If key creation is blocked On a Google Cloud organization (common with Google Workspace accounts) the download can fail with: > Key creation is not allowed on this service account. Please check if service account key creation is restricted by organization policies. Your organization enforces a policy that disables service account keys, and you need an organization admin to lift it: 1. Open [console.cloud.google.com](https://console.cloud.google.com) and switch the resource picker at the top from your project to the **organization**. The role and policy below are only available at the organization scope, not a single project. 2. In **IAM**, grant your own account the **Organization Policy Administrator** role. 3. Go to **IAM & Admin > Organization policies**, find **Disable service account key creation** (`iam.disableServiceAccountKeyCreation`), and set it to **not enforced** for the organization. 4. Wait a few minutes for the change to propagate, then retry **Generate new private key**. ## Configure FCM credentials The contents of that JSON file go into your Rails credentials. Run `bin/rails credentials:edit` and add the `fcm` section: ```yaml action_push_native: fcm: project_id: your-firebase-project-id encryption_key: | { "type": "service_account", "project_id": "your-firebase-project-id", "private_key_id": "...", "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", "client_email": "...", ... } ``` Paste the JSON exactly as Firebase generated it. Keep the inner `private_key` value untouched, including its `\n` escapes. Every line must be indented deeper than `encryption_key:` so YAML reads it as one block. The installer creates `config/push.yml`. Add a `google` section so the gem can send through FCM: ```yaml # config/push.yml shared: google: project_id: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :project_id) %> encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %> ``` If your app also supports iOS, this sits alongside the existing `apple` section. The gem picks the right service per device automatically. -------------------------------------------------------------------------------- Page: Internal testing URL: https://rubynative.com/docs/android/internal-testing -------------------------------------------------------------------------------- # Internal testing Internal testing is the fastest Play track. Builds are available to up to 100 testers within minutes of upload, with no Play review. It needs almost nothing set up: as long as your app exists in Play Console and a valid AAB uploads, testers can install it. The store listing, content rating, and App content declarations are only needed for [production](/docs/android/go-live/submission). ## Trigger your first build 1. From the rubynative.com dashboard, open the app and click the Android build button. 2. Preflight runs in about five seconds. If it fails, fix the underlying issue (usually a missing service account invite, or the app not yet created in Play Console) and retry. 3. The Gradle build runs (2 to 7 minutes) and produces an AAB. 4. The AAB uploads to the Internal Testing track on Play Console automatically. If the AAB build succeeds but the Play upload fails, the build is marked `success` (the AAB is downloadable from the dashboard) and a separate Play-specific error message is attached. Fix the issue it names and re-trigger the build. ## Add testers If you've filled the **Beta testers** field in Ruby Native, the same Play Console panel in the dashboard has a copy-button list of emails. Once the first AAB lands on the Internal Testing track: 1. In Play Console, go to **Testing** -> **Internal testing**. 2. Click the **Testers** tab. 3. Click **Create email list** and paste in tester emails, or share the **Copy link** opt-in URL. 4. Testers install the app from the link on their Android device. Testers must accept the opt-in URL once before they can install the app. The URL stays the same across builds, so you only need to share it once. Google's [set up an open, closed, or internal test](https://support.google.com/googleplay/android-developer/answer/9845334) guide has the full walkthrough. ## Troubleshooting The preflight and upload steps emit specific error messages. Each maps to a setup step: | Message | Cause | Fix | |---|---|---| | Service account doesn't have publisher access for {package_name} | Publisher invite not done or scoped to wrong app | Repeat [invite Ruby Native](/docs/android/invite-publisher) | | Your Play Console listing for {package_name} doesn't exist | App not created in Play Console, or package name mismatch | Repeat [create your app](/docs/android/app-listing) with the bundle ID configured in Ruby Native | If you imported an existing AAB or signing key and Play prompts you to accept App Signing, click through once in Play Console, then retry the build. -------------------------------------------------------------------------------- Page: Go live on Google Play URL: https://rubynative.com/docs/android/go-live -------------------------------------------------------------------------------- # Go live on Google Play A pre-flight checklist for promoting your app from internal testing to the public Play Store. Ruby Native handles the builds, signing, and uploads; this page is everything left, with links to the details. Shipping iOS too? Nothing transfers automatically between the stores, so the App Store has [its own checklist](/docs/ios/go-live). ## What Ruby Native already handles - Code signing and delivery to the Play internal testing track - App icons and launch screens in the correct formats - [Permission strings](/docs/permissions) generated from your dashboard settings - [Screenshots](/docs/screenshots) captured against your deployed site ## The checklist Ruby Native delivers every Android build to [internal testing](/docs/android/internal-testing), which skips review. This list is what stands between internal testing and the public Play Store; the [Play submission guide](/docs/android/go-live/submission) walks through each screen. - **Decide how you'll handle payments.** Same decision as on iOS, different rules. [Payments](/docs/android/go-live/payments) covers Google's. - **Account deletion, plus a web link.** Play also requires a public page where users can request deletion without the app. [More](/docs/android/go-live/data-safety). - **App access credentials.** Play's demo account, under App content. Must work from any country and bypass any one-time codes. [More](/docs/android/go-live/submission). - **Title and descriptions.** No keyword field on Play; the title (30), short description (80), and full description (4,000) are all indexed for search. [More](/docs/android/go-live/submission). - **Graphics.** At least 2 phone screenshots (your App Store set works), a 1024 by 500 feature graphic, and a 512 by 512 icon. [More](/docs/android/go-live/submission). - **Data safety form.** Play's privacy questionnaire, more granular than Apple's. [Walkthrough](/docs/android/go-live/data-safety). - **Content rating and declarations.** The IARC questionnaire plus target audience, ads, news, government, financial, and health forms. Most answers are "no," but every form must be submitted. [More](/docs/android/go-live/submission). - **Check your account type.** Personal developer accounts created after November 2023 need a 12-tester closed test for 14 days before production. Organization accounts skip it. [More](/docs/android/go-live/submission). Then [promote to production](/docs/android/go-live/submission) with managed publishing turned on. Publishing is what ends the free tier: once anyone can download your app from Google Play, new builds need a subscription. First reviews take hours to a week; updates after that usually clear in hours. > Want a second set of eyes on your launch? Turnkey includes hands-on submission help, from your listing through review. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: Payments on Google Play URL: https://rubynative.com/docs/android/go-live/payments -------------------------------------------------------------------------------- # Payments on Google Play Whether your app needs Play billing is the same decision you make [for the App Store](/docs/ios/go-live/payments), under different rules. Here's where things stand as of August 2026. ## The baseline rule If you sell digital goods or subscriptions inside the app, Google requires Play billing. The cuts: 15% on your first $1M each year (30% past that), and 15% on auto-renewing subscriptions from day one. Physical goods, services consumed outside the app, and person-to-person payments are exempt. One thing to know up front: Ruby Native doesn't integrate Play billing yet. [In-app purchases](/docs/in-app-purchases) are StoreKit-only today, so purchases inside your Android app aren't supported; for most apps, the companion path below is the answer. ## The companion app path Play allows apps where users consume content or services they already pay for elsewhere, as long as the app doesn't steer them to buy outside Play. In practice it's the same shape as [Apple's companion path](/docs/ios/go-live/payments): sign-ups and billing live on your website, the app serves existing customers, and nothing in the app mentions upgrading or pricing. ## The US carve-out Since late 2025, US apps can enroll in Google's external links program and send users to web checkout, and a separate reduced-fee model is live in the US, UK, and EEA. Google published fees for the links program (around 10% on subscriptions) that start applying October 1, 2026. Like Apple's version, it only applies to some users, so treat it as something to watch rather than build on. ## What to do - **You sell nothing digital, or only to businesses on the web:** ship the companion path. No purchases, no upgrade CTAs, nothing to build. - **You want purchases inside the Android app:** that needs Play billing, which Ruby Native doesn't support yet. If that's a blocker, reply to any of my emails and let me know. - **You're not sure:** ship the companion path first. Adding purchases later is a normal update. This area changes quarter to quarter. Check the current [Play payments policy](https://support.google.com/googleplay/android-developer/answer/10281818) at submission time. > Not sure which path fits? Turnkey includes help deciding, and handling Google if they push back. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: Data safety URL: https://rubynative.com/docs/android/go-live/data-safety -------------------------------------------------------------------------------- # Data safety Google requires every app to disclose what data it collects and how it's handled through the Data safety form. You fill this out in Play Console before promoting a build to production. ## Where to find it 1. Go to [play.google.com/console](https://play.google.com/console) and open your app. 2. In the left sidebar, open **Policy and programs** then **App content**. 3. Find the **Data safety** card and click **Start** or **Manage**. Play Console walks you through a series of questions about what data your app collects, how it's used, and who it's shared with. Answer based on what your Rails app actually stores, not what Android frameworks are capable of. ## Data types for a typical Rails app Most Rails apps with user accounts collect a few standard data types. Here's what to declare and how to categorize each one. ### Personal info (email address) Almost every Rails app collects an email address for authentication. - **Category:** Personal info - **Data type:** Email address - **Collected:** Yes - **Shared:** No (unless you send it to a third party) - **Optional:** No - **Purposes:** Account management ### Personal info (user ID) If your app has user accounts, you store some form of user ID. - **Category:** Personal info - **Data type:** User IDs - **Collected:** Yes - **Shared:** No - **Optional:** No - **Purposes:** Account management ### Personal info (name) Only declare this if your app collects the user's name during signup or in a profile. - **Category:** Personal info - **Data type:** Name - **Collected:** Yes - **Shared:** No - **Optional:** Depends on your signup flow - **Purposes:** Account management ### App activity (analytics) If your app records how users interact with it, declare it. This includes server-side analytics like page views, feature usage, or event tracking. - **Category:** App activity - **Data type:** App interactions - **Collected:** Yes - **Shared:** Only if you send it to a third party - **Optional:** No - **Purposes:** Analytics ### Device or other IDs (Ruby Native launch ping) Each time the app launches, Ruby Native sends the bundle ID, the platform, and a random identifier generated once per install to rubynative.com. It powers the active devices count on your dashboard and carries no user data. - **Category:** Device or other IDs - **Data type:** Device or other IDs - **Collected:** Yes - **Shared:** No - **Optional:** No - **Purposes:** App functionality, Analytics To skip this entry, turn reporting off with `analytics: false` in `config/ruby_native.yml`; see [usage reporting](/docs/setup#usage-reporting). ### What most Rails apps don't need to declare Unless your app specifically collects these, skip them: - Location - Health and fitness - Financial info - Web browsing history - Contacts - Photos and videos (unless your app uploads them) - Audio files - Files and docs ## Data collection vs sharing Google draws a sharp line between these two and the form asks about each separately. **Collection** means your app sends data off the device to a server you control. Storing user records in your Rails database counts as collection. Almost every Rails app collects email and user IDs. **Sharing** means transferring data to a third party. Sending data to your own server is not sharing. Sending it to Mixpanel, Google Analytics, Stripe, or any other vendor is. Be honest here, Play reviewers cross-check declarations against the SDKs in your AAB. ## Security practices Play Console also asks about how you handle the data once you've collected it. - **Data is encrypted in transit.** Yes if your app uses HTTPS, which it almost certainly does. Ruby Native apps enforce HTTPS by default in production. - **You provide a way for users to request data deletion.** Yes if your app has an account-delete flow or you handle requests via email. Google strongly recommends an in-app option. - **Committed to Play's Families Policy.** Only if your app is designed for or appeals to children. Most B2B and prosumer apps answer no. ## Privacy policy Every app needs a privacy policy URL in Play Console. Add it under **App content** then **Privacy Policy**. Your existing website privacy policy usually works. Just make sure it mentions the Android app and covers push notification device tokens if you use them. ## Keeping the declaration up to date The Data safety form is not a one-time thing. Any time you add a new data type (location, contacts, etc.) or a new third-party SDK (analytics, crash reporting, etc.), update the form before your next production release. Play can reject releases that don't match the declared data practices. Your next step is to [submit your app](/docs/android/go-live/submission). > Need help getting to the Play Store? Turnkey includes hands-on help with your listing, data safety form, and review. https://rubynative.com/pricing -------------------------------------------------------------------------------- Page: Play submission URL: https://rubynative.com/docs/android/go-live/submission -------------------------------------------------------------------------------- # Play submission Ruby Native uploads every Android build to the Internal Testing track. Internal testing skips Play review and needs almost nothing beyond a valid AAB, which makes it the right place to validate your app, but it is not the public Play Store. To go live, you promote a build from internal testing to production yourself in Play Console. This mirrors how iOS works: Ruby Native delivers to TestFlight, and you do the App Store release in App Store Connect. ## Before you can go to production Production has requirements internal testing doesn't. Play blocks a production release until each of these is done in Play Console. ### App content In the left sidebar, open **Policy and programs -> App content** and complete every declaration: - **Content rating.** Fill out and submit the [rating questionnaire](https://support.google.com/googleplay/android-developer/answer/9898843). Answer honestly about your app's content. Submitting it generates the IARC age ratings Play shows on your listing. There's no API for this, so it has to be done by hand once. - **Target audience and content.** Declare the [age groups](https://support.google.com/googleplay/android-developer/answer/9867159) your app is designed for. - **Data safety.** Declare what data your app collects, why, and whether it's shared. See [data safety](/docs/android/go-live/data-safety) for a typical Rails app walkthrough. - **Privacy policy.** Add a privacy policy URL. Required for nearly every app, and your existing web privacy policy usually works. - **Ads, government apps, financial features, health.** Answer the remaining declarations. Most apps answer "no" to all of them. - **App access.** If anything in your app sits behind a login, provide demo credentials reviewers can reuse from any country, any number of times. If your login uses two-factor or one-time codes, the demo account must bypass them. ### Store listing Open **Grow -> Store presence -> Main store listing** and fill out every field: - **App title.** Up to 30 characters. This is what users see on the Play Store. - **Short description.** Up to 80 characters. Appears at the top of your listing. - **Full description.** Up to 4,000 characters. Lead with what your app does. - **Phone screenshots.** At least 2, up to 8 allowed. - **Feature graphic.** 1024 by 500 pixels. Required for every listing. - **App icon.** 512 by 512 pixels. Play wants its own copy even though Ruby Native already uploads one with each build. - **Category.** Pick the one that best describes your app, under **Store settings**. A few tips: - **Lead with your best screenshot.** The first image appears in Play Store search results. - **There's no keyword field.** The title, short description, and full description are all indexed for search, so work your main keywords in naturally. A handful of mentions helps; more reads as spam to both users and Google. - **Watch the metadata policy.** No emojis or ALL CAPS in the title, no "#1" or "best" claims, no price or promo text anywhere in the listing. - **Don't mention Ruby Native or Rails in the description.** Users care about what the app does for them, not how it's built. Play Console shows a checklist on the **Dashboard** and on the **App content** page. Work through it until every item has a green check. ## New developer accounts need a closed test first If your Play developer account is a **personal account created on or after November 13, 2023**, you can't promote straight to production. Google [requires a closed test](https://support.google.com/googleplay/android-developer/answer/14151465) with at least 12 testers opted in continuously for 14 days before you can apply for production access. **Organization (business) developer accounts are not subject to this.** With one of those, you can go straight to production once the checklist above is done. To run the closed test, promote a Ruby Native build to a closed testing track the same way you'd promote to production, choosing **Closed testing** instead of **Production**. Add your testers, leave it running for the full 14 days, then apply for production access from the Play Console **Dashboard**. ## Promote a build to production Once a Ruby Native build has landed on internal testing and you've cleared the checklist above: 1. In Play Console, open **Testing -> Internal testing**. 2. Find the release you want to ship and click **Promote release -> Production**. 3. Review the release. The AAB, version code, and release notes carry over from internal testing, so you can edit the release notes if you want and leave the rest. 4. Set the rollout percentage. A staged rollout, say 20 percent, lets you pause if something goes wrong, while 100 percent ships to everyone at once. 5. Click **Next**, then **Save and publish**, or **Send for review** if this is your first production release. You can also create a fresh production release from **Production -> Create new release** and add the same AAB, but promoting the internal build is fewer steps. Google's [prepare and roll out a release](https://support.google.com/googleplay/android-developer/answer/9859348) guide covers the full release form. ## Play review Google reviews every production release. A brand new app usually takes a few days, while updates to an app that's already live usually clear in a few hours. Turn on managed publishing in Play Console so an approved release waits for you to press the button instead of going live mid-review. Internal testing builds skip review entirely, so a production release sitting in review never holds up your testing track. Most rejections trace back to the App content declarations: a missing privacy policy, an incomplete data safety form, or a content rating that doesn't match what the app actually does. ## Future builds **Going live is what ends the free tier.** Building and testing are free for as long as you like, but once anyone can download your app from Google Play, new builds need a subscription. Subscribe before you publish and nothing interrupts. [See plans](/pricing). With a subscription, every new Ruby Native build still lands on internal testing. Promote it to production the same way whenever you're ready, and returning releases clear review much faster than the first one. > Need help with your submission? Turnkey includes hands-on help with your listing, declarations, and going live. https://rubynative.com/pricing