@oneuptime/react-native-replay 14.0.12 → 14.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "react-native";
16
16
 
17
17
  // src/MobileReplayRecorder.ts
18
- import { AppState } from "react-native";
18
+ import { AppState, Keyboard } from "react-native";
19
19
 
20
20
  // src/Contract.ts
21
21
  var SESSION_REPLAY_WIRE_VERSION = 1;
@@ -51,6 +51,7 @@ var SESSION_REPLAY_CHECKOUT_INTERVAL_MS = 6e4;
51
51
  var SESSION_REPLAY_ROLLING_BUFFER_MS = 6e4;
52
52
  var SESSION_REPLAY_ROLLING_BUFFER_BYTES = 2 * 1024 * 1024;
53
53
  var SESSION_REPLAY_IDLE_ROLLOVER_MS = 30 * 6e4;
54
+ var SESSION_REPLAY_IDLE_PAUSE_MS = 5 * 6e4;
54
55
  var SESSION_REPLAY_MAX_SESSION_MS = 4 * 60 * 6e4;
55
56
  var SESSION_REPLAY_MAX_DECOMPRESSED_FRAME_BYTES = 8 * 1024 * 1024;
56
57
  var MAX_SESSION_REPLAY_CHUNK_BYTES = 2 * 1024 * 1024;
@@ -59,7 +60,7 @@ var SESSION_REPLAY_MAX_OFFLINE_DELAY_MS = 3 * 24 * 60 * 60 * 1e3;
59
60
  var MOBILE_CAPTURE_INTERVAL_MS = 500;
60
61
  var MOBILE_RECORDER_KIND = "rn-view-tree";
61
62
  var SYNTHETIC_RRWEB_VERSION = "synthetic-1";
62
- var RECORDER_VERSION = true ? "14.0.12" : "0.0.0-dev";
63
+ var RECORDER_VERSION = true ? "14.0.13" : "0.0.0-dev";
63
64
  var SESSION_REPLAY_MOBILE_RECORDER_CAPABILITIES = [
64
65
  "mobile-view-tree",
65
66
  "mobile-touch-events",
@@ -112,6 +113,8 @@ var TAGS_CUSTOM_EVENT_TAG = "oneuptime.tags";
112
113
  var VISIBILITY_CUSTOM_EVENT_TAG = "oneuptime.visibility";
113
114
  var TOUCH_CUSTOM_EVENT_TAG = "oneuptime.touch";
114
115
  var SESSION_ROTATED_CUSTOM_EVENT_TAG = "oneuptime.session-rotated";
116
+ var IDLE_PAUSED_CUSTOM_EVENT_TAG = "oneuptime.idle-paused";
117
+ var IDLE_RESUMED_CUSTOM_EVENT_TAG = "oneuptime.idle-resumed";
115
118
 
116
119
  // src/Sanitize.ts
117
120
  var MAX_TRAIT_KEYS = 20;
@@ -2487,6 +2490,18 @@ var ReplaySessionStore = class {
2487
2490
  };
2488
2491
  return { ...this.identity };
2489
2492
  }
2493
+ /*
2494
+ * Forget the stored session without starting the next one. A session
2495
+ * that ends while the recorder is paused for idle has no successor until
2496
+ * the person comes back, and its record - last written by its own final
2497
+ * chunk, so it looks recent - would otherwise let a relaunch in the
2498
+ * meantime carry on a session that is already over. The visitor id stays,
2499
+ * and so does the in-memory identity: nothing more is sent under it, and
2500
+ * the next rotate() writes the new session.
2501
+ */
2502
+ async end() {
2503
+ await this.safeRemove(this.sessionKey);
2504
+ }
2490
2505
  async clear() {
2491
2506
  this.identity = null;
2492
2507
  await Promise.all([
@@ -2945,6 +2960,7 @@ function isTouchPrivate(privacyMap, x2, y) {
2945
2960
  var MAX_DIAGNOSTIC_EVENTS = 100;
2946
2961
  var TOUCH_MOVE_SAMPLE_MS = 50;
2947
2962
  var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
2963
+ var MAX_EVENTS_HELD_FOR_RESUME = 500;
2948
2964
  var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
2949
2965
  "AggregateError",
2950
2966
  "Error",
@@ -3016,6 +3032,7 @@ var MobileReplayRecorder = class {
3016
3032
  __publicField(this, "storage");
3017
3033
  __publicField(this, "nativeViewTree");
3018
3034
  __publicField(this, "appState");
3035
+ __publicField(this, "keyboard");
3019
3036
  __publicField(this, "now");
3020
3037
  __publicField(this, "serializer", new ViewTreeSerializer());
3021
3038
  __publicField(this, "chunkBuffer", new ReplayChunkBuffer());
@@ -3091,8 +3108,10 @@ var MobileReplayRecorder = class {
3091
3108
  * re-snapshots every minute, so it filed a new session every half hour
3092
3109
  * for as long as it stayed open. Now nothing is captured, uploaded or
3093
3110
  * handed to the host as a session id until the user is back - a touch,
3094
- * the app returning to the foreground, or an explicit captureSession() -
3095
- * and that starts the next session (see maybeRotateSession).
3111
+ * the soft keyboard, the app returning to the foreground, or an explicit
3112
+ * captureSession() - and that starts the next session (see
3113
+ * maybeRotateSession). In the foreground the session was almost always
3114
+ * paused for idle first (idlePause), so its footage already ended there.
3096
3115
  */
3097
3116
  __publicField(this, "sealedForIdle", false);
3098
3117
  __publicField(this, "rotatingSession", false);
@@ -3111,9 +3130,58 @@ var MobileReplayRecorder = class {
3111
3130
  __publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
3112
3131
  __publicField(this, "unidentifiedTouchCount", 0);
3113
3132
  __publicField(this, "unidentifiedTouchPrivate", false);
3133
+ /*
3134
+ * When the person last did something: a touch (any touch - one inside a
3135
+ * ReplayMask is still someone using the app, even though it is never
3136
+ * recorded), the app coming back to the foreground, or the app starting.
3137
+ * Not sessionLastActivityUnixMs, which every recorded event moves: a
3138
+ * route change, a track() call or a JS error the app raises by itself is
3139
+ * the app, not the person, and a polling screen would otherwise never
3140
+ * look idle.
3141
+ */
3142
+ __publicField(this, "lastInputUnixMs", 0);
3143
+ /*
3144
+ * The idle pause (SESSION_REPLAY_IDLE_PAUSE_MS, #4208). Set when the
3145
+ * capture tick finds nobody has touched the app for that long: the open
3146
+ * chunk closes with an idle-paused marker as its last event, the capture
3147
+ * timer stops, and from then on nothing is recorded - no view-tree
3148
+ * sample, no checkout, no chunk, and every event the app emits is
3149
+ * dropped rather than queued. The 15-second flush timer is the only one
3150
+ * left, and all it does is end the session (sealForIdle, #4207) if nobody
3151
+ * comes back inside SESSION_REPLAY_IDLE_ROLLOVER_MS.
3152
+ *
3153
+ * It exists because the recorder used to go on sampling the view tree
3154
+ * every 500 ms, with a full checkout every minute, for the whole half
3155
+ * hour before the idle rollover - the phone's CPU and battery and the
3156
+ * customer's upload and storage, all spent on footage of nobody.
3157
+ *
3158
+ * Input inside that window - a touch, the soft keyboard, the app
3159
+ * returning to the foreground - resumes the same session and tab on a
3160
+ * fresh full snapshot with an idle-resumed marker directly behind it
3161
+ * (idleResumeBacklog), at the next chunk index.
3162
+ */
3163
+ __publicField(this, "idlePause", null);
3164
+ /*
3165
+ * Non-null from the input that ends an idle pause until the snapshot it
3166
+ * resumes on is in the buffer. What is recorded meanwhile - the touch
3167
+ * that woke the recorder, the screen it opened, a track() it caused - is
3168
+ * held here and appended behind that snapshot and the idle-resumed
3169
+ * marker, so the chunk after a pause always opens on its snapshot.
3170
+ */
3171
+ __publicField(this, "idleResumeBacklog", null);
3172
+ /*
3173
+ * The soft keyboard is up. Keystrokes reach no view the recorder can see,
3174
+ * so someone writing a long message - a chat, a form, a review - would
3175
+ * otherwise look exactly like nobody touching the app. While it is up the
3176
+ * idle pause waits, and its showing and hiding are input
3177
+ * (onKeyboardChange). The 30-minute rollover does not look at it.
3178
+ */
3179
+ __publicField(this, "softKeyboardVisible", false);
3180
+ __publicField(this, "keyboardSubscriptions", []);
3114
3181
  this.storage = dependencies.storage ?? defaultReplayStorage;
3115
3182
  this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
3116
3183
  this.appState = dependencies.appState ?? AppState;
3184
+ this.keyboard = dependencies.keyboard ?? Keyboard ?? null;
3117
3185
  this.now = dependencies.now ?? defaultNow;
3118
3186
  }
3119
3187
  start(options) {
@@ -3190,6 +3258,8 @@ var MobileReplayRecorder = class {
3190
3258
  this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
3191
3259
  this.touchQueue = Promise.resolve();
3192
3260
  this.resetTouchSequences();
3261
+ this.idlePause = null;
3262
+ this.idleResumeBacklog = null;
3193
3263
  const validated = validateStartOptions(options);
3194
3264
  if (!validated) {
3195
3265
  this.disable("invalid-start-options");
@@ -3269,6 +3339,7 @@ var MobileReplayRecorder = class {
3269
3339
  );
3270
3340
  }
3271
3341
  this.sessionLastActivityUnixMs = nowUnixMs;
3342
+ this.lastInputUnixMs = nowUnixMs;
3272
3343
  if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
3273
3344
  this.disable("not-sampled");
3274
3345
  return false;
@@ -3355,13 +3426,22 @@ var MobileReplayRecorder = class {
3355
3426
  this.diagnosticsStatus = "stopped";
3356
3427
  return;
3357
3428
  }
3358
- if (this.rotationReason(this.now()) !== "idle") {
3429
+ const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
3430
+ const pause = this.idlePause;
3431
+ if (!endedIdle && pause === null) {
3359
3432
  this.appendCustom(
3360
3433
  VISIBILITY_CUSTOM_EVENT_TAG,
3361
3434
  { state: "stopped" },
3362
3435
  void 0,
3363
3436
  true
3364
3437
  );
3438
+ } else if (!endedIdle && pause !== null && this.isPauseInStream(pause)) {
3439
+ this.idleResumeBacklog = null;
3440
+ this.appendMarker(
3441
+ VISIBILITY_CUSTOM_EVENT_TAG,
3442
+ { state: "stopped" },
3443
+ pause.pausedAtUnixMs
3444
+ );
3365
3445
  }
3366
3446
  this.haltCapture();
3367
3447
  await this.closeCurrent(true);
@@ -3510,7 +3590,7 @@ var MobileReplayRecorder = class {
3510
3590
  }
3511
3591
  async setRoute(route) {
3512
3592
  await this.identityQueue;
3513
- if (this.running && !this.acceptingEvents) {
3593
+ if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
3514
3594
  this.route = sanitizeRoute(route);
3515
3595
  return;
3516
3596
  }
@@ -3546,7 +3626,15 @@ var MobileReplayRecorder = class {
3546
3626
  if (!this.running || !this.acceptingEvents) {
3547
3627
  return;
3548
3628
  }
3629
+ const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
3549
3630
  await this.maybeRotateForUser(this.now());
3631
+ if (resume === "resumed") {
3632
+ this.startTimers();
3633
+ await this.captureTick(true, false);
3634
+ if (!this.running || !this.acceptingEvents) {
3635
+ return;
3636
+ }
3637
+ }
3550
3638
  const safeReason2 = truncate(
3551
3639
  (typeof reason === "string" ? reason.trim() : "") || "manual",
3552
3640
  MAX_CAPTURE_REASON_LENGTH
@@ -3562,6 +3650,9 @@ var MobileReplayRecorder = class {
3562
3650
  if (!this.running || !this.acceptingEvents) {
3563
3651
  return;
3564
3652
  }
3653
+ if (this.isIdlePaused()) {
3654
+ return;
3655
+ }
3565
3656
  await this.maybeRotateSession(this.now());
3566
3657
  if (this.sealedForIdle) {
3567
3658
  return;
@@ -3587,9 +3678,18 @@ var MobileReplayRecorder = class {
3587
3678
  if (!this.running || !this.acceptingEvents || !this.foreground) {
3588
3679
  return;
3589
3680
  }
3681
+ const input = this.noteUserInput(this.now());
3682
+ let resumeCapture = null;
3683
+ if (input === "resumed") {
3684
+ this.startTimers();
3685
+ resumeCapture = this.captureTick(true);
3686
+ }
3590
3687
  const generation = this.lifecycleGeneration;
3591
3688
  const operation = this.touchQueue.then(
3592
3689
  async () => {
3690
+ if (resumeCapture) {
3691
+ await resumeCapture;
3692
+ }
3593
3693
  await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
3594
3694
  }
3595
3695
  );
@@ -3643,8 +3743,9 @@ var MobileReplayRecorder = class {
3643
3743
  }
3644
3744
  }
3645
3745
  getDiagnostics() {
3746
+ const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
3646
3747
  return {
3647
- status: this.diagnosticsStatus,
3748
+ status,
3648
3749
  sessionId: this.identity?.sessionId ?? null,
3649
3750
  recorderKind: MOBILE_RECORDER_KIND,
3650
3751
  configEpoch: this.config?.configEpoch ?? 0,
@@ -3726,11 +3827,18 @@ var MobileReplayRecorder = class {
3726
3827
  }
3727
3828
  await this.transport?.restore();
3728
3829
  }
3729
- async captureTick(force = false) {
3830
+ /*
3831
+ * mayPauseForIdle is false only for the snapshot captureSession() takes
3832
+ * while paused, whose marker must be in before the pause check runs.
3833
+ */
3834
+ async captureTick(force = false, mayPauseForIdle = true) {
3730
3835
  const generation = this.lifecycleGeneration;
3731
3836
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
3732
3837
  return;
3733
3838
  }
3839
+ if (this.isIdlePaused()) {
3840
+ return;
3841
+ }
3734
3842
  this.captureGeneration = generation;
3735
3843
  try {
3736
3844
  const nowUnixMs = this.now();
@@ -3746,6 +3854,9 @@ var MobileReplayRecorder = class {
3746
3854
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
3747
3855
  return;
3748
3856
  }
3857
+ if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
3858
+ return;
3859
+ }
3749
3860
  const capturedSessionId = this.identity?.sessionId;
3750
3861
  const tree = await this.nativeViewTree.captureViewTree(this.rootTag);
3751
3862
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.rotatingSession || !capturedSessionId || this.identity?.sessionId !== capturedSessionId) {
@@ -3763,6 +3874,9 @@ var MobileReplayRecorder = class {
3763
3874
  if (this.chunkBuffer.shouldFlush()) {
3764
3875
  await this.closeCurrent(false);
3765
3876
  }
3877
+ if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
3878
+ await this.pauseForIdle(nowUnixMs, generation);
3879
+ }
3766
3880
  } catch (error) {
3767
3881
  if (this.running && this.isCurrentGeneration(generation)) {
3768
3882
  this.disable("native-capture-failed", {
@@ -3794,6 +3908,163 @@ var MobileReplayRecorder = class {
3794
3908
  if (capture.hasFullSnapshot) {
3795
3909
  this.lastFullSnapshotAtUnixMs = nowUnixMs;
3796
3910
  this.forceFullSnapshot = false;
3911
+ this.completeIdleResume(nowUnixMs);
3912
+ }
3913
+ }
3914
+ /*
3915
+ * The person did something: a touch, the soft keyboard showing or
3916
+ * hiding, or the app coming back to the foreground. It ends an idle
3917
+ * pause (see endIdlePause).
3918
+ */
3919
+ noteUserInput(nowUnixMs) {
3920
+ this.lastInputUnixMs = Math.max(this.lastInputUnixMs, nowUnixMs);
3921
+ return this.endIdlePause(nowUnixMs);
3922
+ }
3923
+ /*
3924
+ * What someone being back does to an idle pause, on its own for
3925
+ * captureSession(), which acts on the pause without being input.
3926
+ *
3927
+ * Inside the session's idle window the same session resumes, on the
3928
+ * snapshot of the next capture; the caller starts that capture. Once
3929
+ * the window has run out - or the session already ended idle - the
3930
+ * session is over, and the caller starts the next one through
3931
+ * maybeRotateForUser, as on any idle session; nothing changes here.
3932
+ */
3933
+ endIdlePause(nowUnixMs) {
3934
+ if (this.sealedForIdle) {
3935
+ return "session-ended";
3936
+ }
3937
+ if (!this.isIdlePaused()) {
3938
+ return "none";
3939
+ }
3940
+ if (this.rotationReason(nowUnixMs) !== null) {
3941
+ return "session-ended";
3942
+ }
3943
+ this.idleResumeBacklog = [];
3944
+ this.forceFullSnapshot = true;
3945
+ this.sessionLastActivityUnixMs = Math.max(
3946
+ this.sessionLastActivityUnixMs,
3947
+ nowUnixMs
3948
+ );
3949
+ return "resumed";
3950
+ }
3951
+ /* Paused for idle with nobody back yet: nothing is captured or recorded. */
3952
+ isIdlePaused() {
3953
+ return this.idlePause !== null && this.idleResumeBacklog === null;
3954
+ }
3955
+ /*
3956
+ * Whether the pause's marker is in the stream being recorded now, which
3957
+ * is what owes it the idle-resumed marker and dates a stop at it.
3958
+ */
3959
+ isPauseInStream(pause) {
3960
+ return pause.marked && this.identity?.sessionId === pause.sessionId && this.identity?.tabId === pause.tabId;
3961
+ }
3962
+ /*
3963
+ * Stop capturing: nobody has touched the app for the pause window.
3964
+ *
3965
+ * In this order: the idle-paused marker goes in as the last event of the
3966
+ * open chunk, that chunk goes out through the ordinary path, and the
3967
+ * sampler stops. So the last thing the recording holds before the
3968
+ * stretch nobody recorded says so - and a session the person never comes
3969
+ * back to has already ended there when its idle window runs out, so the
3970
+ * idle seal (sealForIdle) has nothing left to send.
3971
+ *
3972
+ * A recorder holding its pre-roll in memory (error-triggered, or waiting
3973
+ * for consent) has nothing to mark and nothing to send. Its pre-roll is
3974
+ * dropped instead: a trigger once the person is back must not upload a
3975
+ * minute of nobody in front of a snapshot taken after an unrecorded gap.
3976
+ */
3977
+ async pauseForIdle(nowUnixMs, generation) {
3978
+ if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.idlePause !== null || this.sealedForIdle || !this.identity) {
3979
+ return;
3980
+ }
3981
+ const paused = {
3982
+ /*
3983
+ * Never before the session began: one that started with nobody there
3984
+ * (the chunk cap rolled it over) was not idle before it existed.
3985
+ */
3986
+ idleSinceUnixMs: Math.min(
3987
+ nowUnixMs,
3988
+ Math.max(this.lastInputUnixMs, this.identity.sessionStartUnixMs)
3989
+ ),
3990
+ pausedAtUnixMs: nowUnixMs
3991
+ };
3992
+ const marked = this.uploadActive;
3993
+ this.idlePause = {
3994
+ sessionId: this.identity.sessionId,
3995
+ tabId: this.identity.tabId,
3996
+ idleSinceUnixMs: paused.idleSinceUnixMs,
3997
+ pausedAtUnixMs: paused.pausedAtUnixMs,
3998
+ marked
3999
+ };
4000
+ this.stopCaptureTimer();
4001
+ this.diagnostic("idle-paused", {
4002
+ idleForMs: nowUnixMs - paused.idleSinceUnixMs
4003
+ });
4004
+ if (!marked) {
4005
+ this.chunkBuffer.clear();
4006
+ this.rollingBuffer.clear();
4007
+ return;
4008
+ }
4009
+ this.appendMarker(IDLE_PAUSED_CUSTOM_EVENT_TAG, paused, nowUnixMs);
4010
+ await this.closeCurrent(false);
4011
+ }
4012
+ /*
4013
+ * The snapshot the session resumes on is in the buffer: the idle-resumed
4014
+ * marker goes directly behind it, then whatever was recorded since the
4015
+ * input that woke the recorder. Same session, same tab and - the pause
4016
+ * having closed its chunk - the next chunk index.
4017
+ */
4018
+ completeIdleResume(nowUnixMs) {
4019
+ const pause = this.idlePause;
4020
+ const held = this.idleResumeBacklog;
4021
+ if (pause === null || held === null) {
4022
+ return;
4023
+ }
4024
+ this.idlePause = null;
4025
+ this.idleResumeBacklog = null;
4026
+ const resumedAtUnixMs = Math.max(pause.pausedAtUnixMs, nowUnixMs);
4027
+ if (this.isPauseInStream(pause)) {
4028
+ const resumed = {
4029
+ pausedAtUnixMs: pause.pausedAtUnixMs,
4030
+ resumedAtUnixMs
4031
+ };
4032
+ this.appendMarker(
4033
+ IDLE_RESUMED_CUSTOM_EVENT_TAG,
4034
+ resumed,
4035
+ resumedAtUnixMs
4036
+ );
4037
+ }
4038
+ this.releaseHeldEvents(held);
4039
+ this.diagnostic("idle-resumed", {
4040
+ pausedForMs: resumedAtUnixMs - pause.pausedAtUnixMs
4041
+ });
4042
+ }
4043
+ /*
4044
+ * A marker the recorder writes about itself - the idle pause and resume,
4045
+ * and a stop while paused - straight into the open chunk, stamped with
4046
+ * the moment it stands for. None of them is activity (the idle rollover
4047
+ * must not move for them), and none is "now": a stop while paused is
4048
+ * dated at the pause.
4049
+ */
4050
+ appendMarker(tag, payload, timestamp) {
4051
+ const options = {};
4052
+ if (this.options) {
4053
+ options.route = toAppUrl(this.options.mobileAppIdentifier, this.route);
4054
+ }
4055
+ if (!this.chunkBuffer.append(
4056
+ createCustomEvent(tag, payload, timestamp),
4057
+ options
4058
+ )) {
4059
+ this.droppedEvents += 1;
4060
+ }
4061
+ }
4062
+ /* What was held for a resume, in the order it was recorded. */
4063
+ releaseHeldEvents(held) {
4064
+ for (const entry of held) {
4065
+ if (!this.chunkBuffer.append(entry.event, entry.options)) {
4066
+ this.droppedEvents += 1;
4067
+ }
3797
4068
  }
3798
4069
  }
3799
4070
  appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
@@ -3815,10 +4086,9 @@ var MobileReplayRecorder = class {
3815
4086
  this.diagnostic("invalid-event-timestamp");
3816
4087
  return;
3817
4088
  }
3818
- this.sessionLastActivityUnixMs = Math.max(
3819
- this.sessionLastActivityUnixMs,
3820
- event.timestamp
3821
- );
4089
+ if (this.isIdlePaused()) {
4090
+ return;
4091
+ }
3822
4092
  const appendOptions = {};
3823
4093
  if (this.options) {
3824
4094
  appendOptions.route = toAppUrl(
@@ -3829,6 +4099,18 @@ var MobileReplayRecorder = class {
3829
4099
  if (signal) {
3830
4100
  appendOptions.signal = signal;
3831
4101
  }
4102
+ if (this.idleResumeBacklog !== null) {
4103
+ if (this.idleResumeBacklog.length < MAX_EVENTS_HELD_FOR_RESUME) {
4104
+ this.idleResumeBacklog.push({ event, options: appendOptions });
4105
+ } else {
4106
+ this.droppedEvents += 1;
4107
+ }
4108
+ return;
4109
+ }
4110
+ this.sessionLastActivityUnixMs = Math.max(
4111
+ this.sessionLastActivityUnixMs,
4112
+ event.timestamp
4113
+ );
3832
4114
  const accepted = this.chunkBuffer.append(event, appendOptions);
3833
4115
  if (!accepted) {
3834
4116
  this.droppedEvents += 1;
@@ -4014,6 +4296,63 @@ var MobileReplayRecorder = class {
4014
4296
  });
4015
4297
  }
4016
4298
  );
4299
+ this.installKeyboardListeners();
4300
+ }
4301
+ installKeyboardListeners() {
4302
+ this.removeKeyboardListeners();
4303
+ const keyboard = this.keyboard;
4304
+ this.softKeyboardVisible = false;
4305
+ if (!keyboard || typeof keyboard.addListener !== "function") {
4306
+ return;
4307
+ }
4308
+ try {
4309
+ this.softKeyboardVisible = keyboard.isVisible?.() === true;
4310
+ } catch {
4311
+ this.softKeyboardVisible = false;
4312
+ }
4313
+ try {
4314
+ this.keyboardSubscriptions.push(
4315
+ keyboard.addListener("keyboardDidShow", () => {
4316
+ this.onKeyboardChange(true);
4317
+ }),
4318
+ keyboard.addListener("keyboardDidHide", () => {
4319
+ this.onKeyboardChange(false);
4320
+ })
4321
+ );
4322
+ } catch {
4323
+ this.removeKeyboardListeners();
4324
+ this.softKeyboardVisible = false;
4325
+ this.diagnostic("keyboard-subscribe-failed");
4326
+ }
4327
+ }
4328
+ removeKeyboardListeners() {
4329
+ const subscriptions = this.keyboardSubscriptions;
4330
+ this.keyboardSubscriptions = [];
4331
+ for (const subscription of subscriptions) {
4332
+ try {
4333
+ subscription.remove();
4334
+ } catch {
4335
+ }
4336
+ }
4337
+ }
4338
+ /*
4339
+ * The soft keyboard came up or went away. That is the person - a field
4340
+ * tapped, a message sent - so it counts as input and resumes a paused
4341
+ * recording the way a touch does; and while it is up the idle pause
4342
+ * waits, since what is typed on it reaches no view the recorder sees.
4343
+ */
4344
+ onKeyboardChange(visible) {
4345
+ this.softKeyboardVisible = visible;
4346
+ if (!this.running || !this.acceptingEvents || !this.foreground) {
4347
+ return;
4348
+ }
4349
+ const input = this.noteUserInput(this.now());
4350
+ if (input === "resumed") {
4351
+ this.startTimers();
4352
+ void this.captureTick(true);
4353
+ } else if (input === "session-ended") {
4354
+ void this.maybeRotateForUser(this.now());
4355
+ }
4017
4356
  }
4018
4357
  async onAppStateChange(state, generation) {
4019
4358
  if (!this.running || !this.isCurrentGeneration(generation)) {
@@ -4033,6 +4372,13 @@ var MobileReplayRecorder = class {
4033
4372
  if (!this.running || !this.isCurrentGeneration(generation)) {
4034
4373
  return;
4035
4374
  }
4375
+ if (this.idlePause !== null || this.sealedForIdle) {
4376
+ this.idleResumeBacklog = null;
4377
+ if (this.uploadActive) {
4378
+ await this.transport?.drain();
4379
+ }
4380
+ return;
4381
+ }
4036
4382
  this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
4037
4383
  await this.closeCurrent(false);
4038
4384
  if (this.uploadActive) {
@@ -4042,6 +4388,7 @@ var MobileReplayRecorder = class {
4042
4388
  }
4043
4389
  if (isActive && !this.foreground) {
4044
4390
  this.foreground = true;
4391
+ const input = this.noteUserInput(this.now());
4045
4392
  const policyReady = await this.refreshRuntimePolicy(
4046
4393
  generation,
4047
4394
  true,
@@ -4055,7 +4402,10 @@ var MobileReplayRecorder = class {
4055
4402
  if (!this.running || !this.isCurrentGeneration(generation)) {
4056
4403
  return;
4057
4404
  }
4058
- this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
4405
+ const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
4406
+ if (!resumedPausedSession) {
4407
+ this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
4408
+ }
4059
4409
  const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
4060
4410
  if (!openedOnSnapshot) {
4061
4411
  this.forceFullSnapshot = true;
@@ -4081,16 +4431,31 @@ var MobileReplayRecorder = class {
4081
4431
  void this.captureTick();
4082
4432
  }, intervalMs);
4083
4433
  this.flushTimer = setInterval(() => {
4434
+ if (this.isIdlePaused()) {
4435
+ if (this.acceptingEvents) {
4436
+ void this.maybeRotateSession(this.now());
4437
+ }
4438
+ return;
4439
+ }
4084
4440
  if (this.acceptingEvents) {
4085
4441
  void this.closeCurrent(false);
4086
4442
  }
4087
4443
  }, SESSION_REPLAY_FLUSH_INTERVAL_MS);
4088
4444
  }
4089
- clearTimers() {
4445
+ /*
4446
+ * The sampler alone. An idle pause stops it - every tick is a native
4447
+ * traversal of the whole view tree, which is what a pause is for not
4448
+ * paying - and keeps the 15-second flush timer as its heartbeat, rather
4449
+ * than waking the JS thread twice a second to find nothing to do.
4450
+ */
4451
+ stopCaptureTimer() {
4090
4452
  if (this.captureTimer) {
4091
4453
  clearInterval(this.captureTimer);
4092
4454
  this.captureTimer = null;
4093
4455
  }
4456
+ }
4457
+ clearTimers() {
4458
+ this.stopCaptureTimer();
4094
4459
  if (this.flushTimer) {
4095
4460
  clearInterval(this.flushTimer);
4096
4461
  this.flushTimer = null;
@@ -4182,6 +4547,7 @@ var MobileReplayRecorder = class {
4182
4547
  this.clearTimers();
4183
4548
  this.appStateSubscription?.remove();
4184
4549
  this.appStateSubscription = null;
4550
+ this.removeKeyboardListeners();
4185
4551
  this.stopConnectivity();
4186
4552
  this.restoreGlobalErrorHandler();
4187
4553
  }
@@ -4342,8 +4708,12 @@ var MobileReplayRecorder = class {
4342
4708
  if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
4343
4709
  return;
4344
4710
  }
4345
- if (!this.rotationPromise && !this.sealedForIdle && this.rotationReason(this.now()) === "idle") {
4346
- await this.maybeRotateSession(this.now());
4711
+ if (!this.rotationPromise && !this.sealedForIdle) {
4712
+ if (this.isIdlePaused()) {
4713
+ await this.sealForIdleNow();
4714
+ } else if (this.rotationReason(this.now()) === "idle") {
4715
+ await this.maybeRotateSession(this.now());
4716
+ }
4347
4717
  }
4348
4718
  if (this.rotationPromise) {
4349
4719
  await this.rotationPromise;
@@ -4759,6 +5129,9 @@ var MobileReplayRecorder = class {
4759
5129
  reason = "idle";
4760
5130
  } else {
4761
5131
  reason = this.rotationReason(nowUnixMs);
5132
+ if (reason !== null && this.isIdlePaused()) {
5133
+ reason = "idle";
5134
+ }
4762
5135
  }
4763
5136
  if (!reason) {
4764
5137
  return;
@@ -4775,14 +5148,36 @@ var MobileReplayRecorder = class {
4775
5148
  this.rotatingSession = false;
4776
5149
  }
4777
5150
  }
5151
+ /*
5152
+ * The idle seal on demand, under the lock every rotation takes: for an
5153
+ * identity change on a session paused for idle, which no clock has ended
5154
+ * yet but nobody is in.
5155
+ */
5156
+ async sealForIdleNow() {
5157
+ if (this.rotationPromise || this.rotatingSession || this.sealedForIdle) {
5158
+ return;
5159
+ }
5160
+ this.rotatingSession = true;
5161
+ const operation = this.sealForIdle();
5162
+ this.rotationPromise = operation;
5163
+ try {
5164
+ await operation;
5165
+ } finally {
5166
+ if (this.rotationPromise === operation) {
5167
+ this.rotationPromise = null;
5168
+ }
5169
+ this.rotatingSession = false;
5170
+ }
5171
+ }
4778
5172
  /*
4779
5173
  * maybeRotateSession for a user who is here. On an app whose session
4780
- * ended idle, the policy is brought up to date first: no capture tick has
5174
+ * ended idle - or was paused for idle, which stops the capture tick too
5175
+ * (#4208) - the policy is brought up to date first: no capture tick has
4781
5176
  * refreshed it while the app sat idle, and the next session must not open
4782
5177
  * under one that has since changed (or turned replay off).
4783
5178
  */
4784
5179
  async maybeRotateForUser(nowUnixMs) {
4785
- if (this.sealedForIdle && !this.rotatingSession) {
5180
+ if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
4786
5181
  const policyReady = await this.refreshRuntimePolicy(
4787
5182
  this.lifecycleGeneration,
4788
5183
  false,
@@ -4801,10 +5196,14 @@ var MobileReplayRecorder = class {
4801
5196
  * never by now - and with no marker of its own: a marker stamped now
4802
5197
  * would date the session's end half an hour or more after anything the
4803
5198
  * player can show. With nothing buffered nothing is sent, and the server
4804
- * ends the session at its last chunk.
5199
+ * ends the session at its last chunk - for a session paused for idle
5200
+ * (#4208), the chunk its idle-paused marker closed.
4805
5201
  */
4806
5202
  async sealForIdle() {
4807
5203
  this.sealedForIdle = true;
5204
+ this.idlePause = null;
5205
+ this.idleResumeBacklog = null;
5206
+ this.clearTimers();
4808
5207
  this.diagnostic("session-ended-idle", {
4809
5208
  idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
4810
5209
  });
@@ -4819,6 +5218,9 @@ var MobileReplayRecorder = class {
4819
5218
  } else {
4820
5219
  this.rollingBuffer.clear();
4821
5220
  }
5221
+ if (this.running && this.identityPersisted) {
5222
+ await this.sessionStore?.end();
5223
+ }
4822
5224
  }
4823
5225
  async performSessionRotation(nowUnixMs, reason) {
4824
5226
  if (reason !== "idle") {
@@ -4901,6 +5303,8 @@ var MobileReplayRecorder = class {
4901
5303
  }
4902
5304
  this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
4903
5305
  this.sealedForIdle = false;
5306
+ this.idlePause = null;
5307
+ this.idleResumeBacklog = null;
4904
5308
  this.notifySessionChange();
4905
5309
  this.sampled = this.identityPersisted ? deterministicSample(
4906
5310
  this.identity.sessionId,
@@ -4921,6 +5325,10 @@ var MobileReplayRecorder = class {
4921
5325
  if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
4922
5326
  this.disable("not-sampled");
4923
5327
  this.haltCapture();
5328
+ return;
5329
+ }
5330
+ if (this.captureTimer === null && this.running && this.foreground) {
5331
+ this.startTimers();
4924
5332
  }
4925
5333
  }
4926
5334
  disable(code, details) {