Bodygram

PWA Scanner (offline)

Use the Bodygram PWA Scanner for offline-capable scanning across modern browsers, iOS, Android, and hybrid WebView apps.

The Bodygram PWA Scanner is a Progressive Web App build of the scanner that continues to work when the device is offline. Once loaded, it can capture front and side images directly on the user's device without needing a live internet connection — handy for retail, events, and any environment with unreliable connectivity.

The Bodygram PWA Scanner is an experimental feature. It has been tested in common scenarios, but you may hit edge cases on specific devices or browsers. Use it in controlled environments and share feedback so we can improve stability.


Supported environments

The PWA Scanner targets any platform with Progressive Web App support:

  • Modern web browsers (Chrome, Firefox, Safari, Edge)
  • iOS (Safari on iPhone / iPad)
  • Android (Chrome or in-app WebView)
  • Hybrid apps (React Native, Cordova, Flutter — with proper WebView configuration)

Scanner URL

The PWA Scanner is hosted at a single URL — no token required:

https://platform.bodygram.com/pwa/scanner

Query parameters

ParameterDescription
cameraDirectionback to use the rear camera. Omit for the default (front) camera.
screensComma-separated list of screens to show. See Screens. Defaults to all.

Example with the form skipped, gyro alignment enabled, and the back camera selected:

https://platform.bodygram.com/pwa/scanner?screens=gyro-permission,gyro,scan&cameraDirection=back

Screens

nameDescriptionMandatoryNote
formInput form for age, gender, height, weight.NoSkipping this screen makes formData in the completion payload null.
how-to-scanInstructions on how to scan.No
can-hear-voiceInstructions on voice guidance.NoIf this is the first screen, add &tap=true — iOS needs a tap gesture before playing audio.
gyro-permissionInstructions to place/hold the device.NoRequired before the gyro screen.
gyroGyro alignment screen.NoMust be preceded by gyro-permission.
scanCamera + scan UI.Yes
resultScan result screen.No
allAll screens.NoDefault value.

Form data behavior

When you skip the form screen:

  • Only the front and side scan images are returned.
  • payload.formData in the completion event is null.
  • You need to collect height, age, gender, and weight yourself.

Embed in a web page

Load the PWA Scanner in an <iframe> with the required camera and sensor permissions:

<iframe
  src="https://platform.bodygram.com/pwa/scanner"
  allow="camera; microphone; accelerometer; magnetometer; gyroscope"
  style="width: 100%; height: 100%; border: none;"
></iframe>

Using the Bodygram Platform SDK?

If you want parent ↔ iframe communication via the SDK (rather than raw postMessage), load the Bodygram Platform SDK and initialize it with shouldSetupIframe: true. See Embed in an <iframe> in the standard scanner guide.

Receiving events

The PWA Scanner communicates with the parent window using postMessage. Each event has the shape { type, payload }:

typeWhen it firespayload
0User requested to close the scanner.{}
5Model loading status changed.{ status, model }
6Offline scan completed.{ images: { front, side }, formData }formData is null when the form screen is skipped.
window.addEventListener('message', (event) => {
  const { type, payload } = event.data;

  switch (type) {
    case 0:
      console.log('Close requested');
      break;
    case 5:
      console.log('Model status changed', payload);
      break;
    case 6:
      console.log('Offline scan completed', payload);
      console.log('Front image:', payload.images.front);
      console.log('Side image:',  payload.images.side);
      console.log('Form data:',   payload.formData); // may be null
      break;
  }
});

Complete example

A minimal page that opens the PWA Scanner on button click, closes it when requested, and logs the scan result:

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PWA Scanner</title>
    <style>
      * { margin: 0; padding: 0; box-sizing: border-box; }
    </style>
  </head>
  <body>
    <button id="open-scanflow">Open Scanflow</button>
    <div id="anchor" style="width: 100dvw; height: 100dvh;"></div>

    <script>
      function openScanflow() {
        const anchor = document.getElementById('anchor');
        const scanflow = document.createElement('iframe');
        scanflow.src = 'https://platform.bodygram.com/pwa/scanner';
        scanflow.style.width = '100%';
        scanflow.style.height = '100%';
        scanflow.setAttribute(
          'allow',
          'camera; microphone; gyroscope; accelerometer; magnetometer;'
        );
        anchor.appendChild(scanflow);
        document.getElementById('open-scanflow').style.display = 'none';
      }

      function closeScanflow() {
        const anchor = document.getElementById('anchor');
        anchor.removeChild(anchor.firstChild);
        document.getElementById('open-scanflow').style.display = 'block';
      }

      window.addEventListener('message', (event) => {
        const { type, payload } = event.data;

        switch (type) {
          case 0:
            closeScanflow();
            break;
          case 5:
            console.log('Model status changed', payload);
            break;
          case 6:
            console.log('Offline scan completed');
            console.log('Front image:', payload.images.front);
            console.log('Side image:',  payload.images.side);
            console.log('Form data:',   payload.formData);
            break;
        }
      });

      document
        .getElementById('open-scanflow')
        .addEventListener('click', openScanflow);
    </script>
  </body>
</html>

Embed in Android WebView

The general WebView setup is identical to the standard scanner — see the Android WebView guide for manifest permissions, camera permission handling, and WebChromeClient wiring.

The PWA Scanner uses a different JavaScript interface name and exposes only two methods:

Interface nameScanflowJSWebviewInterface
onRequestClose(args)Fires when the user taps the close (×) button. args is "{}".
onOfflineScanComplete(args)Fires when the offline scan completes. args is a JSON string with formData and images.

onOfflineScanComplete payload:

{
  "formData": {
    "age": 25,
    "gender": "male",
    "weight": 70,
    "height": 170,
    "inches": 0,
    "measurementSystem": "metric"
  },
  "images": {
    "front": "<base64 JPEG>",
    "side":  "<base64 JPEG>"
  }
}

A full MainActivity for the PWA Scanner:

package com.example.bsp_integration // ← your package name

import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Bundle
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class OfflineScanFormData(
    val age: Int,
    val gender: String,
    val weight: Int,
    val height: Int,
    val inches: Int,
    val measurementSystem: String,
)

@Serializable
data class OfflineScanImages(
    val front: String,
    val side: String,
)

@Serializable
data class OfflineScanData(
    val formData: OfflineScanFormData,
    val images: OfflineScanImages,
)

class MainActivity : ComponentActivity() {
    private lateinit var webView: WebView
    private lateinit var cameraPermissionRequest: PermissionRequest

    private val registerCameraPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (granted) {
                cameraPermissionRequest.grant(cameraPermissionRequest.resources)
            }
        }

    fun onRequestPermission(request: PermissionRequest) {
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
            == PackageManager.PERMISSION_GRANTED
        ) {
            request.grant(request.resources)
        } else {
            cameraPermissionRequest = request
            registerCameraPermission.launch(Manifest.permission.CAMERA)
        }
    }

    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        webView = findViewById(R.id.webview)
        webView.addJavascriptInterface(
            MyJavaScriptInterface(),
            "ScanflowJSWebviewInterface"
        )
        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true
        webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
        webView.settings.mediaPlaybackRequiresUserGesture = false

        webView.webChromeClient = object : WebChromeClient() {
            override fun onPermissionRequest(request: PermissionRequest) {
                onRequestPermission(request)
            }
        }

        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(
                view: WebView?,
                request: WebResourceRequest?
            ) = false

            override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                super.onPageStarted(view, url, favicon)
                webView.evaluateJavascript(
                    """
                    window.BGScanflowJSWebviewInterface = function(fnName, args) {
                      ScanflowJSWebviewInterface[fnName] && ScanflowJSWebviewInterface[fnName](args);
                    }
                    """.trimIndent(),
                    null
                )
            }
        }

        webView.loadUrl("https://platform.bodygram.com/pwa/scanner")
    }

    inner class MyJavaScriptInterface {
        @JavascriptInterface
        fun onRequestClose(message: String?) {
            Log.d("PWA/close", message.orEmpty())
        }

        @JavascriptInterface
        fun onOfflineScanComplete(message: String?) {
            val data = Json.decodeFromString<OfflineScanData>(message.orEmpty())
            Log.d("PWA/formData",  data.formData.toString())
            Log.d("PWA/frontImage", data.images.front)
            Log.d("PWA/sideImage",  data.images.side)
        }
    }
}

On this page