Skip to main content
Replay records the layout of the screen as a wireframe, never pixels: the kind of each view, its position and size, its style, its state and the touches over it. Text is masked on the device before a frame leaves memory, so what reaches Apsio is already masked.

When a session is recorded

Replay is off until your app turns it on: ApsioReplayOptions(enabled: true) on iOS, replay.enabled = true on Android, and the replay option on React Native. Then a session is recorded when all of these hold: The SDK captures the screen only after something changed (a touch, a new screen, the keyboard, a scroll), at most twice a second, so a still screen costs nothing. Frames are grouped into segments of about ten seconds, compressed and uploaded as attachments; the open segment is closed when the app moves to the background, so a crash loses at most the last few seconds. Withdrawing consent or the kill switch drops the segments not yet sent. Replay is detail: it is kept for your plan’s detail retention (see Pricing). Read a session’s segments with GET /v1/projects/{projectId}/sessions/{sessionId}/replay/segments and each one with GET /v1/projects/{projectId}/attachments/{attachmentId}.

What is masked by default

Masking fails closed. With no setting at all:
  • All text is masked, not only inputs: every character becomes x, and spaces and line breaks stay, so a masked text keeps its length and shape. You can opt down to masking text inputs only (textMasking inputs).
  • Secure fields (passwords, one-time codes, card numbers) are always masked. No setting shows them.
  • Images are boxes.
  • What the SDK cannot read is a hidden box, with nothing inside: web views, maps, video, camera previews, and views that draw their own content. On iOS, SwiftUI content is drawn into plain layers the SDK cannot read, so it is recorded as boxes without content.
  • Touches are recorded only over what is shown: never over masked text, an image, a secure field, the keyboard or a hidden box. One masked or hidden view under a touch is enough to drop it, whatever is drawn above, so taps on a PIN pad of plain buttons do not give the PIN away. You can also turn touches off (touches hide).

Your settings

Each view can be given one of three settings: You set them on a view instance, on a class of views, or for the whole app (the text level), and the remote configuration can add its own. The rules are the same on every platform:
  1. Secure fields are always masked.
  2. Nothing is shown inside a hidden view.
  3. At one level, hide beats mask, which beats unmask.
  4. The nearest setting wins: a view’s own setting beats its class’s, and its class’s beats the app-wide text level. A view without one takes its nearest ancestor’s.

Per view

Tokens in an identifier

A view whose accessibility identifier (iOS), view tag or Compose test tag (Android) or testID (React Native) holds the word apsio-mask, apsio-unmask or apsio-hide gets that setting, as if set on the instance. This is how React Native sets them, and it works in any app. A token counts only as a whole word, exactly as written. A word is a run of ASCII letters, digits and hyphens; every other character, the underscore, the dot and white space included, separates words. So a test identifier never unmasks a view by accident.

Per class

maskClasses, unmaskClasses and hideClasses apply a setting to every view of a class or its subclasses, such as a view a library draws. The SDK matches classes by their name at run time:
  • iOS: the name NSStringFromClass gives. A Swift class carries its module: Checkout.CardField, not CardField. In Swift, pass the class itself (hideClasses: [CardField.self]) and the SDK takes its name.
  • Android: the fully qualified name. R8 renames classes in a minified release build, so keep the name of every class you name, with -keepnames class com.example.checkout.CardView in your R8 rules, or pass the class itself (replay.hideClass(CardView::class.java)), which needs no rule. The SDK keeps the names of the platform, AndroidX, Material and React Native classes it recognizes. See Class names and R8.
  • React Native: the native class names above, one list for both platforms.

Strict mode

A name in a hide or mask list, yours or the remote configuration’s, that matches no class of the app would fail open: the views it was meant to hide would be shown. So a name that does not resolve, whether a typo, a Swift class without its module or a class R8 renamed, puts the session in strict mode for the rest of its replay:
  • every text and input is masked and every image hidden, whatever unmasks them;
  • every view the SDK knows only as its own drawing is hidden;
  • the device logs a warning naming the classes, and the session gets one apsio.replay.strict_mode record whose apsio.replay.unresolved_classes lists them.
The names are checked again for every session, so fixing the name or keeping it from R8 ends strict mode from the next session. A name in an unmask list that does not resolve unmasks nothing and does not start strict mode.

Remote tightening

The remote configuration can mask more without a release: mask all text, which voids every unmask the app set, and add mask and hide classes. Its classes win over every setting of the app, on the view and on everything inside it. It can never loosen masking.

Check what is recorded

The mask preview draws over your app what replay records, from each capture: red where it masks, grey where it hides, and a green outline where touches are recorded. It works whether or not the session records replay, and only in a debug build: Check every screen with the preview before you turn replay on in a release.

What it costs

Reading the screen is the only work on the main thread. The SDKs budget it: when one capture takes longer than 4 ms, captures come less often. Masking the text, comparing frames and compressing them happen on a background thread. A large view tree is cut: past 2,000 views on iOS, the rest of the tree is one hidden box.