Export and upload

A finished scan is only useful once it reaches your backend, and a scan that's lost on the way means sending someone back to the property. This page covers getting the file off the phone reliably.

Export

When the session reaches finished, export() packages the scan into one encrypted file, the bundle. Export works offline, so it never fails for lack of signal. Then close the session to release the camera.

let bundle = try await session.export()
session.close()
val bundle = session.export()
session.close()
const bundle = await session.export();
session.close();

The bundle is a file at bundle.fileURL (iOS), bundle.file (Android) or bundle.path (JavaScript), in your app's private storage and excluded from device backups. bundle.scanId identifies the scan: store it with your upload, and quote it when you contact Roomio about the scan.

If export fails with export_failed and isRetryable is true, call export() again. The scan is kept, so the user doesn't need to scan again.

Upload

Upload the file to your own storage, unchanged, with its scanId. Roomio reads the bundle exactly as the SDK wrote it, so any change to it, such as re-zipping it or sending it as form fields, makes it unreadable. Your backend then sends the scan to Roomio.

Bundles run from about 10 MB to a few hundred MB, and your users are often on a patchy mobile connection at the property. Three habits keep scans from getting lost:

  • Upload in the background. The user shouldn't have to keep your app open while a large file goes up.
  • Retry on failure. Keep the file and try again when the connection comes back, or on the next launch.
  • Delete only once your backend confirms it, with a 2xx response. Until then, the file on the phone is the only copy.

Show the user how the upload went, too: a failed upload nobody sees is a lost scan.

The examples below show one way to do this on each platform.

// A background URLSession keeps uploading while the app is suspended.
final class BundleUploader: NSObject, URLSessionTaskDelegate {
    static let shared = BundleUploader()
    private lazy var session = URLSession(
        configuration: .background(withIdentifier: "com.example.app.roomio-upload"),
        delegate: self, delegateQueue: nil)

    func upload(_ bundle: RoomioBundle, to url: URL) {
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("application/zip", forHTTPHeaderField: "Content-Type")
        request.setValue(bundle.scanId, forHTTPHeaderField: "X-Roomio-Scan-Id")
        let task = session.uploadTask(with: request, fromFile: bundle.fileURL)
        task.taskDescription = bundle.fileURL.path
        task.resume()
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
        if error == nil, (200..<300).contains(status), let path = task.taskDescription {
            try? FileManager.default.removeItem(atPath: path)
        }
        // Otherwise keep the file and tell the user; retry on their tap or next launch.
    }
}
// WorkManager (androidx.work:work-runtime-ktx) retries with backoff and survives the app being killed.
class UploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        val file = File(inputData.getString("path")!!)
        val conn = (URL(UPLOAD_URL).openConnection() as HttpURLConnection).apply {
            requestMethod = "PUT"
            doOutput = true
            setFixedLengthStreamingMode(file.length())
            setRequestProperty("Content-Type", "application/zip")
            setRequestProperty("X-Roomio-Scan-Id", inputData.getString("scanId"))
        }
        try {
            conn.outputStream.use { out -> file.inputStream().use { it.copyTo(out) } }
            when (conn.responseCode) {
                in 200..299 -> { file.delete(); Result.success() }
                in 500..599 -> Result.retry()
                else -> Result.failure()
            }
        } catch (e: IOException) {
            Result.retry()
        } finally {
            conn.disconnect()
        }
    }
}

fun enqueueUpload(context: Context, bundle: RoomioBundle): UUID {
    val request = OneTimeWorkRequestBuilder<UploadWorker>()
        .setInputData(workDataOf("path" to bundle.file.absolutePath, "scanId" to bundle.scanId))
        .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
        .build()
    WorkManager.getInstance(context).enqueueUniqueWork(bundle.file.name, ExistingWorkPolicy.KEEP, request)
    return request.id // observe getWorkInfoByIdFlow(id) to show the result
}
// Expo, with expo-file-system. Runs while the app is open.
import { File } from 'expo-file-system';

export async function uploadBundle(bundle: RoomioBundle, url: string) {
  const file = new File(`file://${bundle.path}`);
  const res = await file.upload(url, {
    httpMethod: 'PUT',
    headers: { 'Content-Type': 'application/zip', 'X-Roomio-Scan-Id': bundle.scanId ?? '' },
  });
  if (res.status < 200 || res.status >= 300) throw new Error(`Upload failed: HTTP ${res.status}`);
  file.delete();
}

// React Native without Expo, with react-native-blob-util. Runs while the app is open.
import ReactNativeBlobUtil from 'react-native-blob-util';

export async function uploadBundle(bundle: RoomioBundle, url: string) {
  const res = await ReactNativeBlobUtil.fetch(
    'PUT',
    url,
    { 'Content-Type': 'application/zip', 'X-Roomio-Scan-Id': bundle.scanId ?? '' },
    ReactNativeBlobUtil.wrap(bundle.path),
  );
  const status = res.info().status;
  if (status < 200 || status >= 300) throw new Error(`Upload failed: HTTP ${status}`);
  await ReactNativeBlobUtil.fs.unlink(bundle.path);
}

// In both: keep failed uploads in a list (AsyncStorage) and retry them on the next launch,
// so a scan isn't lost when the user closes the app mid-upload.

A WorkManager worker is stopped after 10 minutes. If uploads can take longer, make it a long-running worker with setForeground.

Roomio SDK 0.0.15