Quickstart
From a button in your app to a scan file ready to upload.
Every integration does the same five things: configure the SDK once, offer scanning where the device can do it, show the scan view, react when the user ends the scan, and hand the result to your backend. This page walks through each, then gives you a complete example for your platform to paste into a project.
Before you start, install the SDK. Scanning needs a real phone: simulators and emulators can build your app but can't scan.
1. Configure the SDK
configure tells the SDK which organisation it's scanning for. Call it once at launch, before any other SDK call. The publishable key is safe to ship in your app. If Roomio rejects it, publishable_key_rejected shows in the device log, and scanning still works so your users are never blocked.
import RoomioSDK
import SwiftUI
@main
struct MyApp: App {
init() {
Roomio.configure(publishableKey: "roomio_pub_live_…")
}
var body: some Scene {
WindowGroup { HomeView() }
}
}
import android.app.Application
import io.captur3d.roomio.sdk.Roomio
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// The SDK's own scanning process needs none of your app's setup.
if (Roomio.isWorkerProcess()) return
Roomio.configure(this, publishableKey = "roomio_pub_live_…")
}
}
Roomio.configure('roomio_pub_live_…'); // at module load, in App.tsx or the root _layout.tsx
On Android, a scan runs in a separate process of your app, which runs Application.onCreate again. configure() does nothing there. Return early when Roomio.isWorkerProcess() is true, as the example does, so the rest of your setup is skipped there too.
2. Offer scanning where it works
Not every phone can scan, so only show your scan button where it can. On Android, treat anything but unsupported as a yes, including "checking" and "needs update": the scan view installs Google Play Services for AR itself when it's missing. Check again in onResume, since the answer changes once it's installed.
if Roomio.isSupported() == .supported {
// show your scan button
}
if (Roomio.isSupported(context) != RoomioSupport.UNSUPPORTED) {
// show your scan button
}
const support = await Roomio.isSupported();
if (support !== 'unsupported') {
// show your scan button
}
3. Show the scan view
A scan is a session. Start a new one from startScan() each time the user taps your button, show it full screen in RoomioScanView, and call start() straight away.
The scan view does the rest of the talking to your user. It opens with a short onboarding, asks for the camera when that ends, and starts recording as soon as the user is through. While they scan, it shows what they've captured so far, coaches them when they move too fast or lose their place, and gives them Complete scan and close buttons. If you'd rather explain the camera permission in your own words, ask for it before you show the view; the SDK won't ask again.
let session = try Roomio.startScan()
// In your full-screen scan screen:
RoomioScanView(session: session)
.onAppear { session.start() }
val session = Roomio.startScan(activity)
// In your full-screen scan screen:
scanView.bind(session)
session.start()
const session = await Roomio.startScan();
// In your full-screen scan screen:
<RoomioScanView session={session} style={StyleSheet.absoluteFill} />
// and, once it's shown:
session.start();
Keep the session for as long as the scan screen shows, in your screen's state rather than a local variable. On Android, keep the activity that hosts the scan in one orientation: if the activity is recreated, the scan is lost. React Native and Expo handle this for you. If the user leaves your app mid-scan, the scan pauses and carries on when they return.
While the onboarding shows, session.isShowingOnboarding is true, and while a sheet or dialog shows, session.isShowingDialog is true; hide any controls of your own while either is. Onboarding and guidance covers this and how to use your brand's colours.
4. React when the scan ends
The user ends the scan, not your app. Complete scan saves it, and session.state moves through saving to finished. The close button asks the user to confirm, then moves the state to cancelled. A scan can also stop on its own with an error, which moves the state to failed.
Follow session.state and act on those three:
.onChange(of: session.state) { _, state in
switch state {
case .finished: Task { await exportAndUpload() }
case .cancelled: leave()
case .failed(let error): handle(error)
default: break
}
}
session.state.collect { state ->
when (state) {
RoomioScanState.Finished -> exportAndUpload()
RoomioScanState.Cancelled -> leave()
is RoomioScanState.Failed -> handle(state.error)
else -> Unit
}
}
const { state } = useRoomioScanSession(session);
useEffect(() => {
if (state.type === 'finished') exportAndUpload();
if (state.type === 'cancelled') leave();
if (state.type === 'failed') handle(state.error);
}, [state.type]);
New states may arrive in future releases, so always keep a default or else branch. Session lifecycle explains every state, and Handling errors what to do with a failure.
5. Export, upload and close
When the state is finished, export() packages the scan into one encrypted file, the bundle. Hand it to your upload code unchanged, then close() the session to release the camera, and leave the scan screen: close() doesn't dismiss it for you.
let bundle = try await session.export()
upload(bundle.fileURL)
session.close()
val bundle = session.export()
upload(bundle.file)
session.close()
const bundle = await session.export();
upload(bundle.path);
session.close();
A bundle can be a few hundred MB, so upload it in the background and don't delete it until your backend confirms it. Export and upload has background upload code for each platform. From there, your backend sends the scan to Roomio.
Complete example
Each tab is a whole app's scanning flow in one file. upload and handle stand for your own code. For a full project you can build and run, see the samples.
import RoomioSDK
import SwiftUI
@main
struct MyApp: App {
init() {
Roomio.configure(publishableKey: "roomio_pub_live_…")
}
var body: some Scene {
WindowGroup { HomeView() }
}
}
struct HomeView: View {
@State private var session: RoomioScanSession?
var body: some View {
VStack {
if Roomio.isSupported() == .supported {
Button("Scan a property") {
do {
session = try Roomio.startScan()
} catch {
handle(error)
}
}
.buttonStyle(.borderedProminent)
} else {
Text("This device can't scan.")
}
}
.fullScreenCover(item: $session) { session in
ScanScreen(session: session) { self.session = nil }
}
}
}
struct ScanScreen: View {
let session: RoomioScanSession
let onDone: () -> Void
var body: some View {
RoomioScanView(session: session)
.onAppear { session.start() }
.onChange(of: session.state) { _, state in
switch state {
case .finished:
Task {
do {
let bundle = try await session.export()
upload(bundle.fileURL)
leave()
} catch {
handle(error)
}
}
case .cancelled:
leave()
case .failed(let error):
handle(error)
default:
break
}
}
}
private func leave() {
session.close() // releases the camera
onDone() // dismisses this screen
}
}
func upload(_ file: URL) { /* your upload code: see Export and upload */ }
func handle(_ error: Error) { print(error.localizedDescription) }
import Observation
import RoomioSDK
import SwiftUI
import UIKit
// Call Roomio.configure(publishableKey: "roomio_pub_live_…") in your AppDelegate's
// application(_:didFinishLaunchingWithOptions:), and make HomeViewController your window's root.
final class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
var config = UIButton.Configuration.borderedProminent()
config.title = "Scan a property"
let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] _ in
self?.startScan()
})
button.isHidden = Roomio.isSupported() != .supported
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
private func startScan() {
do {
present(ScanViewController(session: try Roomio.startScan()), animated: true)
} catch {
handle(error)
}
}
}
final class ScanViewController: UIViewController {
private let session: RoomioScanSession
private var exporting = false
private var closed = false
init(session: RoomioScanSession) {
self.session = session
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// RoomioScanView is a SwiftUI view, so host it full screen.
let scanView = UIHostingController(rootView: RoomioScanView(session: session))
addChild(scanView)
scanView.view.frame = view.bounds
scanView.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(scanView.view)
scanView.didMove(toParent: self)
session.start()
followState()
}
// withObservationTracking reports only the first change, so register again each time.
private func followState() {
withObservationTracking {
react(to: session.state)
} onChange: { [weak self] in
Task { @MainActor [weak self] in self?.followState() }
}
}
private func react(to state: RoomioScanState) {
switch state {
case .finished where !exporting:
exporting = true
Task {
do {
let bundle = try await session.export()
upload(bundle.fileURL)
leave()
} catch {
handle(error)
}
}
case .cancelled:
leave()
case .failed(let error):
handle(error)
default:
break
}
}
private func leave() {
guard !closed else { return }
closed = true
session.close() // releases the camera
dismiss(animated: true) // dismisses this screen
}
}
func upload(_ file: URL) { /* your upload code: see Export and upload */ }
func handle(_ error: Error) { print(error.localizedDescription) }
Register MyApp as android:name on your manifest's <application>, and set android:screenOrientation="portrait" on MainActivity. The screens collect the session's Flows with androidx.lifecycle:lifecycle-runtime-compose, so add it next to the SDK:
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
import android.app.Application
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.captur3d.roomio.sdk.Roomio
import io.captur3d.roomio.sdk.RoomioError
import io.captur3d.roomio.sdk.RoomioScanSession
import io.captur3d.roomio.sdk.RoomioScanState
import io.captur3d.roomio.sdk.RoomioScanView
import io.captur3d.roomio.sdk.RoomioSupport
import java.io.File
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// The SDK's own scanning process needs none of your app's setup.
if (Roomio.isWorkerProcess()) return
Roomio.configure(this, publishableKey = "roomio_pub_live_…")
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
var session by remember { mutableStateOf<RoomioScanSession?>(null) }
when (val current = session) {
null -> HomeScreen(onScan = {
session = try {
Roomio.startScan(this@MainActivity)
} catch (error: RoomioError) {
handle(error)
null
}
})
else -> key(current) { ScanScreen(current, onDone = { session = null }) }
}
}
}
}
}
}
@Composable
fun HomeScreen(onScan: () -> Unit) {
val context = LocalContext.current
var support by remember { mutableStateOf(Roomio.isSupported(context)) }
// The answer changes once Google Play Services for AR is installed, so check on every resume.
LifecycleResumeEffect(Unit) {
support = Roomio.isSupported(context)
onPauseOrDispose {}
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (support != RoomioSupport.UNSUPPORTED) {
Button(onClick = onScan) { Text("Scan a property") }
} else {
Text("This device can't scan.")
}
}
}
@Composable
fun ScanScreen(session: RoomioScanSession, onDone: () -> Unit) {
val state by session.state.collectAsStateWithLifecycle()
// However the screen goes away, release the camera.
DisposableEffect(session) {
onDispose { session.close() }
}
LaunchedEffect(session) {
session.start()
}
LaunchedEffect(state) {
when (val current = state) {
RoomioScanState.Finished -> try {
upload(session.export().file)
onDone()
} catch (error: RoomioError) {
handle(error)
}
RoomioScanState.Cancelled -> onDone()
is RoomioScanState.Failed -> handle(current.error)
else -> Unit
}
}
AndroidView(
factory = { context -> RoomioScanView(context).apply { bind(session) } },
onRelease = { it.unbind(session) },
modifier = Modifier.fillMaxSize(),
)
}
fun upload(file: File) { /* your upload code: see Export and upload */ }
fun handle(error: RoomioError) { Log.w("Roomio", "${error.code}: ${error.message}") }
RoomioScanView is a regular View, so a Views app needs no Compose. Register MyApp (as in the Compose tab) as android:name on your manifest's <application>, add ScanActivity with android:screenOrientation="portrait", and start it from your scan button with startActivity(Intent(this, ScanActivity::class.java)) wherever Roomio.isSupported(this) isn't UNSUPPORTED. It collects the session's Flows with androidx.lifecycle:lifecycle-runtime-ktx.
res/layout/activity_scan.xml:
<io.captur3d.roomio.sdk.RoomioScanView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scan_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import io.captur3d.roomio.sdk.Roomio
import io.captur3d.roomio.sdk.RoomioError
import io.captur3d.roomio.sdk.RoomioScanSession
import io.captur3d.roomio.sdk.RoomioScanState
import io.captur3d.roomio.sdk.RoomioScanView
import kotlinx.coroutines.launch
import java.io.File
class ScanActivity : AppCompatActivity() {
private var session: RoomioScanSession? = null
private lateinit var scanView: RoomioScanView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val session = try {
Roomio.startScan(this)
} catch (error: RoomioError) {
handle(error)
finish()
return
}
this.session = session
setContentView(R.layout.activity_scan)
scanView = findViewById(R.id.scan_view)
scanView.bind(session)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
session.state.collect { state ->
when (state) {
RoomioScanState.Finished -> try {
upload(session.export().file)
finish()
} catch (error: RoomioError) {
handle(error)
}
RoomioScanState.Cancelled -> finish()
is RoomioScanState.Failed -> handle(state.error)
else -> Unit
}
}
}
}
session.start()
}
override fun onDestroy() {
session?.let {
scanView.unbind(it)
it.close() // releases the camera
}
super.onDestroy()
}
}
fun upload(file: File) { /* your upload code: see Export and upload */ }
fun handle(error: RoomioError) { Log.w("Roomio", "${error.code}: ${error.message}") }
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
Roomio,
RoomioScanView,
useRoomioScanSession,
type RoomioScanSession,
} from '@roomio/react-native-sdk';
Roomio.configure('roomio_pub_live_…');
export default function App() {
const [session, setSession] = useState<RoomioScanSession | null>(null);
const [supported, setSupported] = useState(false);
useEffect(() => {
Roomio.isSupported().then((support) => setSupported(support !== 'unsupported'));
}, []);
if (session) {
return <ScanScreen session={session} onDone={() => setSession(null)} />;
}
return (
<View style={styles.home}>
{supported ? (
<Button title="Scan a property" onPress={() => Roomio.startScan().then(setSession, handle)} />
) : (
<Text>This device can't scan.</Text>
)}
</View>
);
}
function ScanScreen({ session, onDone }: { session: RoomioScanSession; onDone: () => void }) {
const { state } = useRoomioScanSession(session);
function leave() {
session.close(); // releases the camera
onDone(); // leaves this screen
}
useEffect(() => {
session.start();
}, [session]);
useEffect(() => {
if (state.type === 'finished') {
session.export().then((bundle) => {
upload(bundle.path);
leave();
}, handle);
}
if (state.type === 'cancelled') leave();
if (state.type === 'failed') handle(state.error);
}, [state.type, session]);
return <RoomioScanView session={session} style={StyleSheet.absoluteFill} />;
}
function upload(path: string) {
/* your upload code: see Export and upload */
}
function handle(error: unknown) {
console.warn(error);
}
const styles = StyleSheet.create({
home: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});