@oneuptime/react-native-replay 14.0.12 → 14.0.14

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.cjs CHANGED
@@ -90,6 +90,7 @@ var SESSION_REPLAY_CHECKOUT_INTERVAL_MS = 6e4;
90
90
  var SESSION_REPLAY_ROLLING_BUFFER_MS = 6e4;
91
91
  var SESSION_REPLAY_ROLLING_BUFFER_BYTES = 2 * 1024 * 1024;
92
92
  var SESSION_REPLAY_IDLE_ROLLOVER_MS = 30 * 6e4;
93
+ var SESSION_REPLAY_IDLE_PAUSE_MS = 5 * 6e4;
93
94
  var SESSION_REPLAY_MAX_SESSION_MS = 4 * 60 * 6e4;
94
95
  var SESSION_REPLAY_MAX_DECOMPRESSED_FRAME_BYTES = 8 * 1024 * 1024;
95
96
  var MAX_SESSION_REPLAY_CHUNK_BYTES = 2 * 1024 * 1024;
@@ -98,7 +99,7 @@ var SESSION_REPLAY_MAX_OFFLINE_DELAY_MS = 3 * 24 * 60 * 60 * 1e3;
98
99
  var MOBILE_CAPTURE_INTERVAL_MS = 500;
99
100
  var MOBILE_RECORDER_KIND = "rn-view-tree";
100
101
  var SYNTHETIC_RRWEB_VERSION = "synthetic-1";
101
- var RECORDER_VERSION = true ? "14.0.12" : "0.0.0-dev";
102
+ var RECORDER_VERSION = true ? "14.0.14" : "0.0.0-dev";
102
103
  var SESSION_REPLAY_MOBILE_RECORDER_CAPABILITIES = [
103
104
  "mobile-view-tree",
104
105
  "mobile-touch-events",
@@ -151,6 +152,8 @@ var TAGS_CUSTOM_EVENT_TAG = "oneuptime.tags";
151
152
  var VISIBILITY_CUSTOM_EVENT_TAG = "oneuptime.visibility";
152
153
  var TOUCH_CUSTOM_EVENT_TAG = "oneuptime.touch";
153
154
  var SESSION_ROTATED_CUSTOM_EVENT_TAG = "oneuptime.session-rotated";
155
+ var IDLE_PAUSED_CUSTOM_EVENT_TAG = "oneuptime.idle-paused";
156
+ var IDLE_RESUMED_CUSTOM_EVENT_TAG = "oneuptime.idle-resumed";
154
157
 
155
158
  // src/Sanitize.ts
156
159
  var MAX_TRAIT_KEYS = 20;
@@ -2526,6 +2529,18 @@ var ReplaySessionStore = class {
2526
2529
  };
2527
2530
  return { ...this.identity };
2528
2531
  }
2532
+ /*
2533
+ * Forget the stored session without starting the next one. A session
2534
+ * that ends while the recorder is paused for idle has no successor until
2535
+ * the person comes back, and its record - last written by its own final
2536
+ * chunk, so it looks recent - would otherwise let a relaunch in the
2537
+ * meantime carry on a session that is already over. The visitor id stays,
2538
+ * and so does the in-memory identity: nothing more is sent under it, and
2539
+ * the next rotate() writes the new session.
2540
+ */
2541
+ async end() {
2542
+ await this.safeRemove(this.sessionKey);
2543
+ }
2529
2544
  async clear() {
2530
2545
  this.identity = null;
2531
2546
  await Promise.all([
@@ -2984,6 +2999,7 @@ function isTouchPrivate(privacyMap, x2, y) {
2984
2999
  var MAX_DIAGNOSTIC_EVENTS = 100;
2985
3000
  var TOUCH_MOVE_SAMPLE_MS = 50;
2986
3001
  var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
3002
+ var MAX_EVENTS_HELD_FOR_RESUME = 500;
2987
3003
  var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
2988
3004
  "AggregateError",
2989
3005
  "Error",
@@ -3055,6 +3071,7 @@ var MobileReplayRecorder = class {
3055
3071
  __publicField(this, "storage");
3056
3072
  __publicField(this, "nativeViewTree");
3057
3073
  __publicField(this, "appState");
3074
+ __publicField(this, "keyboard");
3058
3075
  __publicField(this, "now");
3059
3076
  __publicField(this, "serializer", new ViewTreeSerializer());
3060
3077
  __publicField(this, "chunkBuffer", new ReplayChunkBuffer());
@@ -3130,8 +3147,10 @@ var MobileReplayRecorder = class {
3130
3147
  * re-snapshots every minute, so it filed a new session every half hour
3131
3148
  * for as long as it stayed open. Now nothing is captured, uploaded or
3132
3149
  * handed to the host as a session id until the user is back - a touch,
3133
- * the app returning to the foreground, or an explicit captureSession() -
3134
- * and that starts the next session (see maybeRotateSession).
3150
+ * the soft keyboard, the app returning to the foreground, or an explicit
3151
+ * captureSession() - and that starts the next session (see
3152
+ * maybeRotateSession). In the foreground the session was almost always
3153
+ * paused for idle first (idlePause), so its footage already ended there.
3135
3154
  */
3136
3155
  __publicField(this, "sealedForIdle", false);
3137
3156
  __publicField(this, "rotatingSession", false);
@@ -3150,9 +3169,58 @@ var MobileReplayRecorder = class {
3150
3169
  __publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
3151
3170
  __publicField(this, "unidentifiedTouchCount", 0);
3152
3171
  __publicField(this, "unidentifiedTouchPrivate", false);
3172
+ /*
3173
+ * When the person last did something: a touch (any touch - one inside a
3174
+ * ReplayMask is still someone using the app, even though it is never
3175
+ * recorded), the app coming back to the foreground, or the app starting.
3176
+ * Not sessionLastActivityUnixMs, which every recorded event moves: a
3177
+ * route change, a track() call or a JS error the app raises by itself is
3178
+ * the app, not the person, and a polling screen would otherwise never
3179
+ * look idle.
3180
+ */
3181
+ __publicField(this, "lastInputUnixMs", 0);
3182
+ /*
3183
+ * The idle pause (SESSION_REPLAY_IDLE_PAUSE_MS, #4208). Set when the
3184
+ * capture tick finds nobody has touched the app for that long: the open
3185
+ * chunk closes with an idle-paused marker as its last event, the capture
3186
+ * timer stops, and from then on nothing is recorded - no view-tree
3187
+ * sample, no checkout, no chunk, and every event the app emits is
3188
+ * dropped rather than queued. The 15-second flush timer is the only one
3189
+ * left, and all it does is end the session (sealForIdle, #4207) if nobody
3190
+ * comes back inside SESSION_REPLAY_IDLE_ROLLOVER_MS.
3191
+ *
3192
+ * It exists because the recorder used to go on sampling the view tree
3193
+ * every 500 ms, with a full checkout every minute, for the whole half
3194
+ * hour before the idle rollover - the phone's CPU and battery and the
3195
+ * customer's upload and storage, all spent on footage of nobody.
3196
+ *
3197
+ * Input inside that window - a touch, the soft keyboard, the app
3198
+ * returning to the foreground - resumes the same session and tab on a
3199
+ * fresh full snapshot with an idle-resumed marker directly behind it
3200
+ * (idleResumeBacklog), at the next chunk index.
3201
+ */
3202
+ __publicField(this, "idlePause", null);
3203
+ /*
3204
+ * Non-null from the input that ends an idle pause until the snapshot it
3205
+ * resumes on is in the buffer. What is recorded meanwhile - the touch
3206
+ * that woke the recorder, the screen it opened, a track() it caused - is
3207
+ * held here and appended behind that snapshot and the idle-resumed
3208
+ * marker, so the chunk after a pause always opens on its snapshot.
3209
+ */
3210
+ __publicField(this, "idleResumeBacklog", null);
3211
+ /*
3212
+ * The soft keyboard is up. Keystrokes reach no view the recorder can see,
3213
+ * so someone writing a long message - a chat, a form, a review - would
3214
+ * otherwise look exactly like nobody touching the app. While it is up the
3215
+ * idle pause waits, and its showing and hiding are input
3216
+ * (onKeyboardChange). The 30-minute rollover does not look at it.
3217
+ */
3218
+ __publicField(this, "softKeyboardVisible", false);
3219
+ __publicField(this, "keyboardSubscriptions", []);
3153
3220
  this.storage = dependencies.storage ?? defaultReplayStorage;
3154
3221
  this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
3155
3222
  this.appState = dependencies.appState ?? import_react_native2.AppState;
3223
+ this.keyboard = dependencies.keyboard ?? import_react_native2.Keyboard ?? null;
3156
3224
  this.now = dependencies.now ?? defaultNow;
3157
3225
  }
3158
3226
  start(options) {
@@ -3229,6 +3297,8 @@ var MobileReplayRecorder = class {
3229
3297
  this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
3230
3298
  this.touchQueue = Promise.resolve();
3231
3299
  this.resetTouchSequences();
3300
+ this.idlePause = null;
3301
+ this.idleResumeBacklog = null;
3232
3302
  const validated = validateStartOptions(options);
3233
3303
  if (!validated) {
3234
3304
  this.disable("invalid-start-options");
@@ -3308,6 +3378,7 @@ var MobileReplayRecorder = class {
3308
3378
  );
3309
3379
  }
3310
3380
  this.sessionLastActivityUnixMs = nowUnixMs;
3381
+ this.lastInputUnixMs = nowUnixMs;
3311
3382
  if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
3312
3383
  this.disable("not-sampled");
3313
3384
  return false;
@@ -3394,13 +3465,22 @@ var MobileReplayRecorder = class {
3394
3465
  this.diagnosticsStatus = "stopped";
3395
3466
  return;
3396
3467
  }
3397
- if (this.rotationReason(this.now()) !== "idle") {
3468
+ const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
3469
+ const pause = this.idlePause;
3470
+ if (!endedIdle && pause === null) {
3398
3471
  this.appendCustom(
3399
3472
  VISIBILITY_CUSTOM_EVENT_TAG,
3400
3473
  { state: "stopped" },
3401
3474
  void 0,
3402
3475
  true
3403
3476
  );
3477
+ } else if (!endedIdle && pause !== null && this.isPauseInStream(pause)) {
3478
+ this.idleResumeBacklog = null;
3479
+ this.appendMarker(
3480
+ VISIBILITY_CUSTOM_EVENT_TAG,
3481
+ { state: "stopped" },
3482
+ pause.pausedAtUnixMs
3483
+ );
3404
3484
  }
3405
3485
  this.haltCapture();
3406
3486
  await this.closeCurrent(true);
@@ -3549,7 +3629,7 @@ var MobileReplayRecorder = class {
3549
3629
  }
3550
3630
  async setRoute(route) {
3551
3631
  await this.identityQueue;
3552
- if (this.running && !this.acceptingEvents) {
3632
+ if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
3553
3633
  this.route = sanitizeRoute(route);
3554
3634
  return;
3555
3635
  }
@@ -3585,7 +3665,15 @@ var MobileReplayRecorder = class {
3585
3665
  if (!this.running || !this.acceptingEvents) {
3586
3666
  return;
3587
3667
  }
3668
+ const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
3588
3669
  await this.maybeRotateForUser(this.now());
3670
+ if (resume === "resumed") {
3671
+ this.startTimers();
3672
+ await this.captureTick(true, false);
3673
+ if (!this.running || !this.acceptingEvents) {
3674
+ return;
3675
+ }
3676
+ }
3589
3677
  const safeReason2 = truncate(
3590
3678
  (typeof reason === "string" ? reason.trim() : "") || "manual",
3591
3679
  MAX_CAPTURE_REASON_LENGTH
@@ -3601,6 +3689,9 @@ var MobileReplayRecorder = class {
3601
3689
  if (!this.running || !this.acceptingEvents) {
3602
3690
  return;
3603
3691
  }
3692
+ if (this.isIdlePaused()) {
3693
+ return;
3694
+ }
3604
3695
  await this.maybeRotateSession(this.now());
3605
3696
  if (this.sealedForIdle) {
3606
3697
  return;
@@ -3626,9 +3717,18 @@ var MobileReplayRecorder = class {
3626
3717
  if (!this.running || !this.acceptingEvents || !this.foreground) {
3627
3718
  return;
3628
3719
  }
3720
+ const input = this.noteUserInput(this.now());
3721
+ let resumeCapture = null;
3722
+ if (input === "resumed") {
3723
+ this.startTimers();
3724
+ resumeCapture = this.captureTick(true);
3725
+ }
3629
3726
  const generation = this.lifecycleGeneration;
3630
3727
  const operation = this.touchQueue.then(
3631
3728
  async () => {
3729
+ if (resumeCapture) {
3730
+ await resumeCapture;
3731
+ }
3632
3732
  await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
3633
3733
  }
3634
3734
  );
@@ -3682,8 +3782,9 @@ var MobileReplayRecorder = class {
3682
3782
  }
3683
3783
  }
3684
3784
  getDiagnostics() {
3785
+ const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
3685
3786
  return {
3686
- status: this.diagnosticsStatus,
3787
+ status,
3687
3788
  sessionId: this.identity?.sessionId ?? null,
3688
3789
  recorderKind: MOBILE_RECORDER_KIND,
3689
3790
  configEpoch: this.config?.configEpoch ?? 0,
@@ -3765,11 +3866,18 @@ var MobileReplayRecorder = class {
3765
3866
  }
3766
3867
  await this.transport?.restore();
3767
3868
  }
3768
- async captureTick(force = false) {
3869
+ /*
3870
+ * mayPauseForIdle is false only for the snapshot captureSession() takes
3871
+ * while paused, whose marker must be in before the pause check runs.
3872
+ */
3873
+ async captureTick(force = false, mayPauseForIdle = true) {
3769
3874
  const generation = this.lifecycleGeneration;
3770
3875
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
3771
3876
  return;
3772
3877
  }
3878
+ if (this.isIdlePaused()) {
3879
+ return;
3880
+ }
3773
3881
  this.captureGeneration = generation;
3774
3882
  try {
3775
3883
  const nowUnixMs = this.now();
@@ -3785,6 +3893,9 @@ var MobileReplayRecorder = class {
3785
3893
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
3786
3894
  return;
3787
3895
  }
3896
+ if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
3897
+ return;
3898
+ }
3788
3899
  const capturedSessionId = this.identity?.sessionId;
3789
3900
  const tree = await this.nativeViewTree.captureViewTree(this.rootTag);
3790
3901
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.rotatingSession || !capturedSessionId || this.identity?.sessionId !== capturedSessionId) {
@@ -3802,6 +3913,9 @@ var MobileReplayRecorder = class {
3802
3913
  if (this.chunkBuffer.shouldFlush()) {
3803
3914
  await this.closeCurrent(false);
3804
3915
  }
3916
+ if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
3917
+ await this.pauseForIdle(nowUnixMs, generation);
3918
+ }
3805
3919
  } catch (error) {
3806
3920
  if (this.running && this.isCurrentGeneration(generation)) {
3807
3921
  this.disable("native-capture-failed", {
@@ -3833,6 +3947,163 @@ var MobileReplayRecorder = class {
3833
3947
  if (capture.hasFullSnapshot) {
3834
3948
  this.lastFullSnapshotAtUnixMs = nowUnixMs;
3835
3949
  this.forceFullSnapshot = false;
3950
+ this.completeIdleResume(nowUnixMs);
3951
+ }
3952
+ }
3953
+ /*
3954
+ * The person did something: a touch, the soft keyboard showing or
3955
+ * hiding, or the app coming back to the foreground. It ends an idle
3956
+ * pause (see endIdlePause).
3957
+ */
3958
+ noteUserInput(nowUnixMs) {
3959
+ this.lastInputUnixMs = Math.max(this.lastInputUnixMs, nowUnixMs);
3960
+ return this.endIdlePause(nowUnixMs);
3961
+ }
3962
+ /*
3963
+ * What someone being back does to an idle pause, on its own for
3964
+ * captureSession(), which acts on the pause without being input.
3965
+ *
3966
+ * Inside the session's idle window the same session resumes, on the
3967
+ * snapshot of the next capture; the caller starts that capture. Once
3968
+ * the window has run out - or the session already ended idle - the
3969
+ * session is over, and the caller starts the next one through
3970
+ * maybeRotateForUser, as on any idle session; nothing changes here.
3971
+ */
3972
+ endIdlePause(nowUnixMs) {
3973
+ if (this.sealedForIdle) {
3974
+ return "session-ended";
3975
+ }
3976
+ if (!this.isIdlePaused()) {
3977
+ return "none";
3978
+ }
3979
+ if (this.rotationReason(nowUnixMs) !== null) {
3980
+ return "session-ended";
3981
+ }
3982
+ this.idleResumeBacklog = [];
3983
+ this.forceFullSnapshot = true;
3984
+ this.sessionLastActivityUnixMs = Math.max(
3985
+ this.sessionLastActivityUnixMs,
3986
+ nowUnixMs
3987
+ );
3988
+ return "resumed";
3989
+ }
3990
+ /* Paused for idle with nobody back yet: nothing is captured or recorded. */
3991
+ isIdlePaused() {
3992
+ return this.idlePause !== null && this.idleResumeBacklog === null;
3993
+ }
3994
+ /*
3995
+ * Whether the pause's marker is in the stream being recorded now, which
3996
+ * is what owes it the idle-resumed marker and dates a stop at it.
3997
+ */
3998
+ isPauseInStream(pause) {
3999
+ return pause.marked && this.identity?.sessionId === pause.sessionId && this.identity?.tabId === pause.tabId;
4000
+ }
4001
+ /*
4002
+ * Stop capturing: nobody has touched the app for the pause window.
4003
+ *
4004
+ * In this order: the idle-paused marker goes in as the last event of the
4005
+ * open chunk, that chunk goes out through the ordinary path, and the
4006
+ * sampler stops. So the last thing the recording holds before the
4007
+ * stretch nobody recorded says so - and a session the person never comes
4008
+ * back to has already ended there when its idle window runs out, so the
4009
+ * idle seal (sealForIdle) has nothing left to send.
4010
+ *
4011
+ * A recorder holding its pre-roll in memory (error-triggered, or waiting
4012
+ * for consent) has nothing to mark and nothing to send. Its pre-roll is
4013
+ * dropped instead: a trigger once the person is back must not upload a
4014
+ * minute of nobody in front of a snapshot taken after an unrecorded gap.
4015
+ */
4016
+ async pauseForIdle(nowUnixMs, generation) {
4017
+ if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.idlePause !== null || this.sealedForIdle || !this.identity) {
4018
+ return;
4019
+ }
4020
+ const paused = {
4021
+ /*
4022
+ * Never before the session began: one that started with nobody there
4023
+ * (the chunk cap rolled it over) was not idle before it existed.
4024
+ */
4025
+ idleSinceUnixMs: Math.min(
4026
+ nowUnixMs,
4027
+ Math.max(this.lastInputUnixMs, this.identity.sessionStartUnixMs)
4028
+ ),
4029
+ pausedAtUnixMs: nowUnixMs
4030
+ };
4031
+ const marked = this.uploadActive;
4032
+ this.idlePause = {
4033
+ sessionId: this.identity.sessionId,
4034
+ tabId: this.identity.tabId,
4035
+ idleSinceUnixMs: paused.idleSinceUnixMs,
4036
+ pausedAtUnixMs: paused.pausedAtUnixMs,
4037
+ marked
4038
+ };
4039
+ this.stopCaptureTimer();
4040
+ this.diagnostic("idle-paused", {
4041
+ idleForMs: nowUnixMs - paused.idleSinceUnixMs
4042
+ });
4043
+ if (!marked) {
4044
+ this.chunkBuffer.clear();
4045
+ this.rollingBuffer.clear();
4046
+ return;
4047
+ }
4048
+ this.appendMarker(IDLE_PAUSED_CUSTOM_EVENT_TAG, paused, nowUnixMs);
4049
+ await this.closeCurrent(false);
4050
+ }
4051
+ /*
4052
+ * The snapshot the session resumes on is in the buffer: the idle-resumed
4053
+ * marker goes directly behind it, then whatever was recorded since the
4054
+ * input that woke the recorder. Same session, same tab and - the pause
4055
+ * having closed its chunk - the next chunk index.
4056
+ */
4057
+ completeIdleResume(nowUnixMs) {
4058
+ const pause = this.idlePause;
4059
+ const held = this.idleResumeBacklog;
4060
+ if (pause === null || held === null) {
4061
+ return;
4062
+ }
4063
+ this.idlePause = null;
4064
+ this.idleResumeBacklog = null;
4065
+ const resumedAtUnixMs = Math.max(pause.pausedAtUnixMs, nowUnixMs);
4066
+ if (this.isPauseInStream(pause)) {
4067
+ const resumed = {
4068
+ pausedAtUnixMs: pause.pausedAtUnixMs,
4069
+ resumedAtUnixMs
4070
+ };
4071
+ this.appendMarker(
4072
+ IDLE_RESUMED_CUSTOM_EVENT_TAG,
4073
+ resumed,
4074
+ resumedAtUnixMs
4075
+ );
4076
+ }
4077
+ this.releaseHeldEvents(held);
4078
+ this.diagnostic("idle-resumed", {
4079
+ pausedForMs: resumedAtUnixMs - pause.pausedAtUnixMs
4080
+ });
4081
+ }
4082
+ /*
4083
+ * A marker the recorder writes about itself - the idle pause and resume,
4084
+ * and a stop while paused - straight into the open chunk, stamped with
4085
+ * the moment it stands for. None of them is activity (the idle rollover
4086
+ * must not move for them), and none is "now": a stop while paused is
4087
+ * dated at the pause.
4088
+ */
4089
+ appendMarker(tag, payload, timestamp) {
4090
+ const options = {};
4091
+ if (this.options) {
4092
+ options.route = toAppUrl(this.options.mobileAppIdentifier, this.route);
4093
+ }
4094
+ if (!this.chunkBuffer.append(
4095
+ createCustomEvent(tag, payload, timestamp),
4096
+ options
4097
+ )) {
4098
+ this.droppedEvents += 1;
4099
+ }
4100
+ }
4101
+ /* What was held for a resume, in the order it was recorded. */
4102
+ releaseHeldEvents(held) {
4103
+ for (const entry of held) {
4104
+ if (!this.chunkBuffer.append(entry.event, entry.options)) {
4105
+ this.droppedEvents += 1;
4106
+ }
3836
4107
  }
3837
4108
  }
3838
4109
  appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
@@ -3854,10 +4125,9 @@ var MobileReplayRecorder = class {
3854
4125
  this.diagnostic("invalid-event-timestamp");
3855
4126
  return;
3856
4127
  }
3857
- this.sessionLastActivityUnixMs = Math.max(
3858
- this.sessionLastActivityUnixMs,
3859
- event.timestamp
3860
- );
4128
+ if (this.isIdlePaused()) {
4129
+ return;
4130
+ }
3861
4131
  const appendOptions = {};
3862
4132
  if (this.options) {
3863
4133
  appendOptions.route = toAppUrl(
@@ -3868,6 +4138,18 @@ var MobileReplayRecorder = class {
3868
4138
  if (signal) {
3869
4139
  appendOptions.signal = signal;
3870
4140
  }
4141
+ if (this.idleResumeBacklog !== null) {
4142
+ if (this.idleResumeBacklog.length < MAX_EVENTS_HELD_FOR_RESUME) {
4143
+ this.idleResumeBacklog.push({ event, options: appendOptions });
4144
+ } else {
4145
+ this.droppedEvents += 1;
4146
+ }
4147
+ return;
4148
+ }
4149
+ this.sessionLastActivityUnixMs = Math.max(
4150
+ this.sessionLastActivityUnixMs,
4151
+ event.timestamp
4152
+ );
3871
4153
  const accepted = this.chunkBuffer.append(event, appendOptions);
3872
4154
  if (!accepted) {
3873
4155
  this.droppedEvents += 1;
@@ -4053,6 +4335,63 @@ var MobileReplayRecorder = class {
4053
4335
  });
4054
4336
  }
4055
4337
  );
4338
+ this.installKeyboardListeners();
4339
+ }
4340
+ installKeyboardListeners() {
4341
+ this.removeKeyboardListeners();
4342
+ const keyboard = this.keyboard;
4343
+ this.softKeyboardVisible = false;
4344
+ if (!keyboard || typeof keyboard.addListener !== "function") {
4345
+ return;
4346
+ }
4347
+ try {
4348
+ this.softKeyboardVisible = keyboard.isVisible?.() === true;
4349
+ } catch {
4350
+ this.softKeyboardVisible = false;
4351
+ }
4352
+ try {
4353
+ this.keyboardSubscriptions.push(
4354
+ keyboard.addListener("keyboardDidShow", () => {
4355
+ this.onKeyboardChange(true);
4356
+ }),
4357
+ keyboard.addListener("keyboardDidHide", () => {
4358
+ this.onKeyboardChange(false);
4359
+ })
4360
+ );
4361
+ } catch {
4362
+ this.removeKeyboardListeners();
4363
+ this.softKeyboardVisible = false;
4364
+ this.diagnostic("keyboard-subscribe-failed");
4365
+ }
4366
+ }
4367
+ removeKeyboardListeners() {
4368
+ const subscriptions = this.keyboardSubscriptions;
4369
+ this.keyboardSubscriptions = [];
4370
+ for (const subscription of subscriptions) {
4371
+ try {
4372
+ subscription.remove();
4373
+ } catch {
4374
+ }
4375
+ }
4376
+ }
4377
+ /*
4378
+ * The soft keyboard came up or went away. That is the person - a field
4379
+ * tapped, a message sent - so it counts as input and resumes a paused
4380
+ * recording the way a touch does; and while it is up the idle pause
4381
+ * waits, since what is typed on it reaches no view the recorder sees.
4382
+ */
4383
+ onKeyboardChange(visible) {
4384
+ this.softKeyboardVisible = visible;
4385
+ if (!this.running || !this.acceptingEvents || !this.foreground) {
4386
+ return;
4387
+ }
4388
+ const input = this.noteUserInput(this.now());
4389
+ if (input === "resumed") {
4390
+ this.startTimers();
4391
+ void this.captureTick(true);
4392
+ } else if (input === "session-ended") {
4393
+ void this.maybeRotateForUser(this.now());
4394
+ }
4056
4395
  }
4057
4396
  async onAppStateChange(state, generation) {
4058
4397
  if (!this.running || !this.isCurrentGeneration(generation)) {
@@ -4072,6 +4411,13 @@ var MobileReplayRecorder = class {
4072
4411
  if (!this.running || !this.isCurrentGeneration(generation)) {
4073
4412
  return;
4074
4413
  }
4414
+ if (this.idlePause !== null || this.sealedForIdle) {
4415
+ this.idleResumeBacklog = null;
4416
+ if (this.uploadActive) {
4417
+ await this.transport?.drain();
4418
+ }
4419
+ return;
4420
+ }
4075
4421
  this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
4076
4422
  await this.closeCurrent(false);
4077
4423
  if (this.uploadActive) {
@@ -4081,6 +4427,7 @@ var MobileReplayRecorder = class {
4081
4427
  }
4082
4428
  if (isActive && !this.foreground) {
4083
4429
  this.foreground = true;
4430
+ const input = this.noteUserInput(this.now());
4084
4431
  const policyReady = await this.refreshRuntimePolicy(
4085
4432
  generation,
4086
4433
  true,
@@ -4094,7 +4441,10 @@ var MobileReplayRecorder = class {
4094
4441
  if (!this.running || !this.isCurrentGeneration(generation)) {
4095
4442
  return;
4096
4443
  }
4097
- this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
4444
+ const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
4445
+ if (!resumedPausedSession) {
4446
+ this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
4447
+ }
4098
4448
  const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
4099
4449
  if (!openedOnSnapshot) {
4100
4450
  this.forceFullSnapshot = true;
@@ -4120,16 +4470,31 @@ var MobileReplayRecorder = class {
4120
4470
  void this.captureTick();
4121
4471
  }, intervalMs);
4122
4472
  this.flushTimer = setInterval(() => {
4473
+ if (this.isIdlePaused()) {
4474
+ if (this.acceptingEvents) {
4475
+ void this.maybeRotateSession(this.now());
4476
+ }
4477
+ return;
4478
+ }
4123
4479
  if (this.acceptingEvents) {
4124
4480
  void this.closeCurrent(false);
4125
4481
  }
4126
4482
  }, SESSION_REPLAY_FLUSH_INTERVAL_MS);
4127
4483
  }
4128
- clearTimers() {
4484
+ /*
4485
+ * The sampler alone. An idle pause stops it - every tick is a native
4486
+ * traversal of the whole view tree, which is what a pause is for not
4487
+ * paying - and keeps the 15-second flush timer as its heartbeat, rather
4488
+ * than waking the JS thread twice a second to find nothing to do.
4489
+ */
4490
+ stopCaptureTimer() {
4129
4491
  if (this.captureTimer) {
4130
4492
  clearInterval(this.captureTimer);
4131
4493
  this.captureTimer = null;
4132
4494
  }
4495
+ }
4496
+ clearTimers() {
4497
+ this.stopCaptureTimer();
4133
4498
  if (this.flushTimer) {
4134
4499
  clearInterval(this.flushTimer);
4135
4500
  this.flushTimer = null;
@@ -4221,6 +4586,7 @@ var MobileReplayRecorder = class {
4221
4586
  this.clearTimers();
4222
4587
  this.appStateSubscription?.remove();
4223
4588
  this.appStateSubscription = null;
4589
+ this.removeKeyboardListeners();
4224
4590
  this.stopConnectivity();
4225
4591
  this.restoreGlobalErrorHandler();
4226
4592
  }
@@ -4381,8 +4747,12 @@ var MobileReplayRecorder = class {
4381
4747
  if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
4382
4748
  return;
4383
4749
  }
4384
- if (!this.rotationPromise && !this.sealedForIdle && this.rotationReason(this.now()) === "idle") {
4385
- await this.maybeRotateSession(this.now());
4750
+ if (!this.rotationPromise && !this.sealedForIdle) {
4751
+ if (this.isIdlePaused()) {
4752
+ await this.sealForIdleNow();
4753
+ } else if (this.rotationReason(this.now()) === "idle") {
4754
+ await this.maybeRotateSession(this.now());
4755
+ }
4386
4756
  }
4387
4757
  if (this.rotationPromise) {
4388
4758
  await this.rotationPromise;
@@ -4798,6 +5168,9 @@ var MobileReplayRecorder = class {
4798
5168
  reason = "idle";
4799
5169
  } else {
4800
5170
  reason = this.rotationReason(nowUnixMs);
5171
+ if (reason !== null && this.isIdlePaused()) {
5172
+ reason = "idle";
5173
+ }
4801
5174
  }
4802
5175
  if (!reason) {
4803
5176
  return;
@@ -4814,14 +5187,36 @@ var MobileReplayRecorder = class {
4814
5187
  this.rotatingSession = false;
4815
5188
  }
4816
5189
  }
5190
+ /*
5191
+ * The idle seal on demand, under the lock every rotation takes: for an
5192
+ * identity change on a session paused for idle, which no clock has ended
5193
+ * yet but nobody is in.
5194
+ */
5195
+ async sealForIdleNow() {
5196
+ if (this.rotationPromise || this.rotatingSession || this.sealedForIdle) {
5197
+ return;
5198
+ }
5199
+ this.rotatingSession = true;
5200
+ const operation = this.sealForIdle();
5201
+ this.rotationPromise = operation;
5202
+ try {
5203
+ await operation;
5204
+ } finally {
5205
+ if (this.rotationPromise === operation) {
5206
+ this.rotationPromise = null;
5207
+ }
5208
+ this.rotatingSession = false;
5209
+ }
5210
+ }
4817
5211
  /*
4818
5212
  * maybeRotateSession for a user who is here. On an app whose session
4819
- * ended idle, the policy is brought up to date first: no capture tick has
5213
+ * ended idle - or was paused for idle, which stops the capture tick too
5214
+ * (#4208) - the policy is brought up to date first: no capture tick has
4820
5215
  * refreshed it while the app sat idle, and the next session must not open
4821
5216
  * under one that has since changed (or turned replay off).
4822
5217
  */
4823
5218
  async maybeRotateForUser(nowUnixMs) {
4824
- if (this.sealedForIdle && !this.rotatingSession) {
5219
+ if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
4825
5220
  const policyReady = await this.refreshRuntimePolicy(
4826
5221
  this.lifecycleGeneration,
4827
5222
  false,
@@ -4840,10 +5235,14 @@ var MobileReplayRecorder = class {
4840
5235
  * never by now - and with no marker of its own: a marker stamped now
4841
5236
  * would date the session's end half an hour or more after anything the
4842
5237
  * player can show. With nothing buffered nothing is sent, and the server
4843
- * ends the session at its last chunk.
5238
+ * ends the session at its last chunk - for a session paused for idle
5239
+ * (#4208), the chunk its idle-paused marker closed.
4844
5240
  */
4845
5241
  async sealForIdle() {
4846
5242
  this.sealedForIdle = true;
5243
+ this.idlePause = null;
5244
+ this.idleResumeBacklog = null;
5245
+ this.clearTimers();
4847
5246
  this.diagnostic("session-ended-idle", {
4848
5247
  idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
4849
5248
  });
@@ -4858,6 +5257,9 @@ var MobileReplayRecorder = class {
4858
5257
  } else {
4859
5258
  this.rollingBuffer.clear();
4860
5259
  }
5260
+ if (this.running && this.identityPersisted) {
5261
+ await this.sessionStore?.end();
5262
+ }
4861
5263
  }
4862
5264
  async performSessionRotation(nowUnixMs, reason) {
4863
5265
  if (reason !== "idle") {
@@ -4940,6 +5342,8 @@ var MobileReplayRecorder = class {
4940
5342
  }
4941
5343
  this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
4942
5344
  this.sealedForIdle = false;
5345
+ this.idlePause = null;
5346
+ this.idleResumeBacklog = null;
4943
5347
  this.notifySessionChange();
4944
5348
  this.sampled = this.identityPersisted ? deterministicSample(
4945
5349
  this.identity.sessionId,
@@ -4960,6 +5364,10 @@ var MobileReplayRecorder = class {
4960
5364
  if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
4961
5365
  this.disable("not-sampled");
4962
5366
  this.haltCapture();
5367
+ return;
5368
+ }
5369
+ if (this.captureTimer === null && this.running && this.foreground) {
5370
+ this.startTimers();
4963
5371
  }
4964
5372
  }
4965
5373
  disable(code, details) {