@onekeyfe/react-native-background-thread 3.0.17 → 3.0.19
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.
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
#include <jni.h>
|
|
2
2
|
#include <jsi/jsi.h>
|
|
3
3
|
#include <android/log.h>
|
|
4
|
+
#include <atomic>
|
|
5
|
+
#include <chrono>
|
|
6
|
+
#include <condition_variable>
|
|
7
|
+
#include <deque>
|
|
8
|
+
#include <functional>
|
|
4
9
|
#include <memory>
|
|
5
10
|
#include <mutex>
|
|
6
11
|
#include <string>
|
|
12
|
+
#include <thread>
|
|
7
13
|
#include <unordered_map>
|
|
14
|
+
#include <unordered_set>
|
|
8
15
|
|
|
9
16
|
#include "SharedStore.h"
|
|
10
17
|
#include "SharedRPC.h"
|
|
@@ -86,6 +93,358 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeExecuteWork(
|
|
|
86
93
|
} catch (const std::exception &e) {
|
|
87
94
|
LOGE("Error in nativeExecuteWork: %s", e.what());
|
|
88
95
|
}
|
|
96
|
+
|
|
97
|
+
// CRITICAL: Drain the Hermes microtask queue. React Native 0.74+ configures
|
|
98
|
+
// Hermes with an explicit microtask queue, which must be manually drained
|
|
99
|
+
// after each JS execution. Without this, Promise.then() / async-await
|
|
100
|
+
// continuations (including already-resolved promises) are never executed,
|
|
101
|
+
// causing all awaits to hang forever in the background runtime.
|
|
102
|
+
try {
|
|
103
|
+
rt->drainMicrotasks();
|
|
104
|
+
} catch (const jsi::JSError &e) {
|
|
105
|
+
LOGE("JSError draining microtasks: %s", e.getMessage().c_str());
|
|
106
|
+
} catch (const std::exception &e) {
|
|
107
|
+
LOGE("Error draining microtasks: %s", e.what());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Timer support for background runtime ──────────────────────────────
|
|
112
|
+
// The background Hermes runtime does NOT have working setTimeout/setInterval
|
|
113
|
+
// out of the box (RN's timer module only wires into the main runtime). We
|
|
114
|
+
// install our own JSI-level setTimeout/setInterval/clearTimeout/clearInterval
|
|
115
|
+
// backed by a single C++ worker thread that dispatches callbacks back to the
|
|
116
|
+
// background JS queue via the same executor used by SharedRPC.
|
|
117
|
+
|
|
118
|
+
struct TimerEntry {
|
|
119
|
+
std::shared_ptr<jsi::Function> callback;
|
|
120
|
+
long long fireAtMs; // Absolute time in ms when the timer should fire.
|
|
121
|
+
long long intervalMs; // 0 if one-shot, >0 if setInterval period.
|
|
122
|
+
bool cancelled;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
static std::mutex gTimerMutex;
|
|
126
|
+
static std::condition_variable gTimerCv;
|
|
127
|
+
static std::unordered_map<int64_t, TimerEntry> gTimers;
|
|
128
|
+
static std::atomic<int64_t> gNextTimerId{1};
|
|
129
|
+
static std::atomic<bool> gTimerWorkerStarted{false};
|
|
130
|
+
static std::atomic<bool> gTimerWorkerStop{false};
|
|
131
|
+
static RPCRuntimeExecutor gBgTimerExecutor;
|
|
132
|
+
static std::thread gTimerWorkerThread;
|
|
133
|
+
|
|
134
|
+
static long long nowMs() {
|
|
135
|
+
using namespace std::chrono;
|
|
136
|
+
return duration_cast<milliseconds>(
|
|
137
|
+
steady_clock::now().time_since_epoch())
|
|
138
|
+
.count();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Called on the bg JS thread. Executes the callback only; the worker has
|
|
142
|
+
// already erased (one-shot) or rescheduled (interval) the timer under lock.
|
|
143
|
+
static void fireTimerOnJsThread(
|
|
144
|
+
int64_t timerId,
|
|
145
|
+
std::shared_ptr<jsi::Function> cb,
|
|
146
|
+
jsi::Runtime &rt) {
|
|
147
|
+
if (!cb) return;
|
|
148
|
+
try {
|
|
149
|
+
cb->call(rt);
|
|
150
|
+
} catch (const jsi::JSError &e) {
|
|
151
|
+
LOGE("Timer %lld callback JSError: %s", (long long)timerId,
|
|
152
|
+
e.getMessage().c_str());
|
|
153
|
+
} catch (const std::exception &e) {
|
|
154
|
+
LOGE("Timer %lld callback error: %s", (long long)timerId, e.what());
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static void timerWorkerLoop() {
|
|
159
|
+
while (!gTimerWorkerStop.load()) {
|
|
160
|
+
// Snapshot of timers that should be dispatched this iteration and
|
|
161
|
+
// their callbacks. Captured under the lock; callbacks are invoked on
|
|
162
|
+
// the JS thread (not here).
|
|
163
|
+
std::vector<std::pair<int64_t, std::shared_ptr<jsi::Function>>> toFire;
|
|
164
|
+
RPCRuntimeExecutor executor;
|
|
165
|
+
{
|
|
166
|
+
std::unique_lock<std::mutex> lock(gTimerMutex);
|
|
167
|
+
if (gTimers.empty()) {
|
|
168
|
+
gTimerCv.wait(lock, [] {
|
|
169
|
+
return gTimerWorkerStop.load() || !gTimers.empty();
|
|
170
|
+
});
|
|
171
|
+
if (gTimerWorkerStop.load()) return;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Find the earliest fireAt among non-cancelled timers.
|
|
176
|
+
long long earliest = LLONG_MAX;
|
|
177
|
+
for (auto &kv : gTimers) {
|
|
178
|
+
if (!kv.second.cancelled && kv.second.fireAtMs < earliest) {
|
|
179
|
+
earliest = kv.second.fireAtMs;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
long long now = nowMs();
|
|
183
|
+
if (earliest == LLONG_MAX) {
|
|
184
|
+
// Only cancelled timers remain; clean them up.
|
|
185
|
+
for (auto it = gTimers.begin(); it != gTimers.end();) {
|
|
186
|
+
if (it->second.cancelled) it = gTimers.erase(it);
|
|
187
|
+
else ++it;
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (earliest > now) {
|
|
192
|
+
gTimerCv.wait_for(
|
|
193
|
+
lock, std::chrono::milliseconds(earliest - now));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Collect ready timers AND either erase (one-shot) or reschedule
|
|
198
|
+
// (interval) them RIGHT HERE under the lock. This is critical:
|
|
199
|
+
// if we wait to erase in fireTimerOnJsThread, the next worker
|
|
200
|
+
// iteration would immediately find the same timers still
|
|
201
|
+
// in-queue and re-dispatch them, causing an infinite flood of
|
|
202
|
+
// `scheduleOnJSThread` calls.
|
|
203
|
+
for (auto it = gTimers.begin(); it != gTimers.end();) {
|
|
204
|
+
if (it->second.cancelled) {
|
|
205
|
+
it = gTimers.erase(it);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (it->second.fireAtMs <= now) {
|
|
209
|
+
toFire.emplace_back(it->first, it->second.callback);
|
|
210
|
+
if (it->second.intervalMs > 0) {
|
|
211
|
+
// Reschedule interval. Use `now + intervalMs` rather
|
|
212
|
+
// than `fireAtMs + intervalMs` so a slow fire path
|
|
213
|
+
// cannot produce an infinite backlog.
|
|
214
|
+
it->second.fireAtMs = now + it->second.intervalMs;
|
|
215
|
+
++it;
|
|
216
|
+
} else {
|
|
217
|
+
it = gTimers.erase(it);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
++it;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
executor = gBgTimerExecutor;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!executor) {
|
|
227
|
+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (auto &pair : toFire) {
|
|
231
|
+
int64_t id = pair.first;
|
|
232
|
+
std::shared_ptr<jsi::Function> cb = pair.second;
|
|
233
|
+
executor([id, cb](jsi::Runtime &rt) {
|
|
234
|
+
fireTimerOnJsThread(id, cb, rt);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
static void ensureTimerWorkerStarted() {
|
|
241
|
+
bool expected = false;
|
|
242
|
+
if (gTimerWorkerStarted.compare_exchange_strong(expected, true)) {
|
|
243
|
+
gTimerWorkerStop.store(false);
|
|
244
|
+
gTimerWorkerThread = std::thread(timerWorkerLoop);
|
|
245
|
+
LOGI("Timer worker thread started");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static int64_t scheduleTimer(
|
|
250
|
+
std::shared_ptr<jsi::Function> cb,
|
|
251
|
+
double ms,
|
|
252
|
+
bool isInterval) {
|
|
253
|
+
int64_t id = gNextTimerId.fetch_add(1);
|
|
254
|
+
long long intervalMs = isInterval ? static_cast<long long>(ms) : 0;
|
|
255
|
+
long long delay = static_cast<long long>(ms);
|
|
256
|
+
if (delay < 0) delay = 0;
|
|
257
|
+
{
|
|
258
|
+
std::lock_guard<std::mutex> lock(gTimerMutex);
|
|
259
|
+
gTimers[id] = TimerEntry{
|
|
260
|
+
std::move(cb),
|
|
261
|
+
nowMs() + delay,
|
|
262
|
+
intervalMs,
|
|
263
|
+
false,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
gTimerCv.notify_all();
|
|
267
|
+
ensureTimerWorkerStarted();
|
|
268
|
+
return id;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
static void cancelTimer(int64_t id) {
|
|
272
|
+
{
|
|
273
|
+
std::lock_guard<std::mutex> lock(gTimerMutex);
|
|
274
|
+
auto it = gTimers.find(id);
|
|
275
|
+
if (it != gTimers.end()) {
|
|
276
|
+
it->second.cancelled = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
gTimerCv.notify_all();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
static void installTimersOnRuntime(jsi::Runtime &rt) {
|
|
283
|
+
auto makeSetter = [](bool isInterval) {
|
|
284
|
+
return [isInterval](
|
|
285
|
+
jsi::Runtime &rt,
|
|
286
|
+
const jsi::Value &,
|
|
287
|
+
const jsi::Value *args,
|
|
288
|
+
size_t count) -> jsi::Value {
|
|
289
|
+
if (count < 1 || !args[0].isObject() ||
|
|
290
|
+
!args[0].getObject(rt).isFunction(rt)) {
|
|
291
|
+
return jsi::Value::undefined();
|
|
292
|
+
}
|
|
293
|
+
auto cb = std::make_shared<jsi::Function>(
|
|
294
|
+
args[0].getObject(rt).getFunction(rt));
|
|
295
|
+
double ms = 0;
|
|
296
|
+
if (count >= 2 && args[1].isNumber()) {
|
|
297
|
+
ms = args[1].asNumber();
|
|
298
|
+
}
|
|
299
|
+
int64_t id = scheduleTimer(std::move(cb), ms, isInterval);
|
|
300
|
+
return jsi::Value(static_cast<double>(id));
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
auto makeCanceller = []() {
|
|
304
|
+
return [](jsi::Runtime &rt,
|
|
305
|
+
const jsi::Value &,
|
|
306
|
+
const jsi::Value *args,
|
|
307
|
+
size_t count) -> jsi::Value {
|
|
308
|
+
if (count < 1 || !args[0].isNumber()) {
|
|
309
|
+
return jsi::Value::undefined();
|
|
310
|
+
}
|
|
311
|
+
int64_t id = static_cast<int64_t>(args[0].asNumber());
|
|
312
|
+
cancelTimer(id);
|
|
313
|
+
return jsi::Value::undefined();
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// requestAnimationFrame(cb): fires after ~16ms (60fps) with high-resolution
|
|
318
|
+
// timestamp arg, matching the DOM contract. Background runtime has no
|
|
319
|
+
// rendering concept, so we just approximate via setTimeout(16ms).
|
|
320
|
+
auto rafFn = [](jsi::Runtime &rt,
|
|
321
|
+
const jsi::Value &,
|
|
322
|
+
const jsi::Value *args,
|
|
323
|
+
size_t count) -> jsi::Value {
|
|
324
|
+
if (count < 1 || !args[0].isObject() ||
|
|
325
|
+
!args[0].getObject(rt).isFunction(rt)) {
|
|
326
|
+
return jsi::Value::undefined();
|
|
327
|
+
}
|
|
328
|
+
// Wrap callback so it receives a DOMHighResTimeStamp-like arg.
|
|
329
|
+
auto userCb = std::make_shared<jsi::Function>(
|
|
330
|
+
args[0].getObject(rt).getFunction(rt));
|
|
331
|
+
auto wrapper = jsi::Function::createFromHostFunction(
|
|
332
|
+
rt,
|
|
333
|
+
jsi::PropNameID::forAscii(rt, "rafWrapper"),
|
|
334
|
+
0,
|
|
335
|
+
[userCb](jsi::Runtime &rt2,
|
|
336
|
+
const jsi::Value &,
|
|
337
|
+
const jsi::Value *,
|
|
338
|
+
size_t) -> jsi::Value {
|
|
339
|
+
try {
|
|
340
|
+
userCb->call(rt2, jsi::Value(static_cast<double>(nowMs())));
|
|
341
|
+
} catch (const jsi::JSError &e) {
|
|
342
|
+
LOGE("rAF callback JSError: %s", e.getMessage().c_str());
|
|
343
|
+
} catch (const std::exception &e) {
|
|
344
|
+
LOGE("rAF callback error: %s", e.what());
|
|
345
|
+
}
|
|
346
|
+
return jsi::Value::undefined();
|
|
347
|
+
});
|
|
348
|
+
auto wrappedCb = std::make_shared<jsi::Function>(std::move(wrapper));
|
|
349
|
+
int64_t id = scheduleTimer(std::move(wrappedCb), 16.0, false);
|
|
350
|
+
return jsi::Value(static_cast<double>(id));
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// requestIdleCallback(cb, {timeout?}): fires "soon" with an IdleDeadline-ish
|
|
354
|
+
// object. Background runtime has no render frames to be idle between, so
|
|
355
|
+
// we approximate via setTimeout(1ms) and provide a deadline stub whose
|
|
356
|
+
// timeRemaining() always returns 50 (reasonable budget).
|
|
357
|
+
auto ricFn = [](jsi::Runtime &rt,
|
|
358
|
+
const jsi::Value &,
|
|
359
|
+
const jsi::Value *args,
|
|
360
|
+
size_t count) -> jsi::Value {
|
|
361
|
+
if (count < 1 || !args[0].isObject() ||
|
|
362
|
+
!args[0].getObject(rt).isFunction(rt)) {
|
|
363
|
+
return jsi::Value::undefined();
|
|
364
|
+
}
|
|
365
|
+
auto userCb = std::make_shared<jsi::Function>(
|
|
366
|
+
args[0].getObject(rt).getFunction(rt));
|
|
367
|
+
auto wrapper = jsi::Function::createFromHostFunction(
|
|
368
|
+
rt,
|
|
369
|
+
jsi::PropNameID::forAscii(rt, "ricWrapper"),
|
|
370
|
+
0,
|
|
371
|
+
[userCb](jsi::Runtime &rt2,
|
|
372
|
+
const jsi::Value &,
|
|
373
|
+
const jsi::Value *,
|
|
374
|
+
size_t) -> jsi::Value {
|
|
375
|
+
try {
|
|
376
|
+
// Build a minimal IdleDeadline: { didTimeout: false,
|
|
377
|
+
// timeRemaining: () => 50 }.
|
|
378
|
+
jsi::Object deadline(rt2);
|
|
379
|
+
deadline.setProperty(rt2, "didTimeout", jsi::Value(false));
|
|
380
|
+
deadline.setProperty(
|
|
381
|
+
rt2,
|
|
382
|
+
"timeRemaining",
|
|
383
|
+
jsi::Function::createFromHostFunction(
|
|
384
|
+
rt2,
|
|
385
|
+
jsi::PropNameID::forAscii(rt2, "timeRemaining"),
|
|
386
|
+
0,
|
|
387
|
+
[](jsi::Runtime &,
|
|
388
|
+
const jsi::Value &,
|
|
389
|
+
const jsi::Value *,
|
|
390
|
+
size_t) -> jsi::Value {
|
|
391
|
+
return jsi::Value(50.0);
|
|
392
|
+
}));
|
|
393
|
+
userCb->call(rt2, jsi::Value(rt2, std::move(deadline)));
|
|
394
|
+
} catch (const jsi::JSError &e) {
|
|
395
|
+
LOGE("rIC callback JSError: %s", e.getMessage().c_str());
|
|
396
|
+
} catch (const std::exception &e) {
|
|
397
|
+
LOGE("rIC callback error: %s", e.what());
|
|
398
|
+
}
|
|
399
|
+
return jsi::Value::undefined();
|
|
400
|
+
});
|
|
401
|
+
auto wrappedCb = std::make_shared<jsi::Function>(std::move(wrapper));
|
|
402
|
+
int64_t id = scheduleTimer(std::move(wrappedCb), 1.0, false);
|
|
403
|
+
return jsi::Value(static_cast<double>(id));
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
auto global = rt.global();
|
|
407
|
+
global.setProperty(
|
|
408
|
+
rt, "setTimeout",
|
|
409
|
+
jsi::Function::createFromHostFunction(
|
|
410
|
+
rt, jsi::PropNameID::forAscii(rt, "setTimeout"), 2,
|
|
411
|
+
makeSetter(false)));
|
|
412
|
+
global.setProperty(
|
|
413
|
+
rt, "setInterval",
|
|
414
|
+
jsi::Function::createFromHostFunction(
|
|
415
|
+
rt, jsi::PropNameID::forAscii(rt, "setInterval"), 2,
|
|
416
|
+
makeSetter(true)));
|
|
417
|
+
global.setProperty(
|
|
418
|
+
rt, "clearTimeout",
|
|
419
|
+
jsi::Function::createFromHostFunction(
|
|
420
|
+
rt, jsi::PropNameID::forAscii(rt, "clearTimeout"), 1,
|
|
421
|
+
makeCanceller()));
|
|
422
|
+
global.setProperty(
|
|
423
|
+
rt, "clearInterval",
|
|
424
|
+
jsi::Function::createFromHostFunction(
|
|
425
|
+
rt, jsi::PropNameID::forAscii(rt, "clearInterval"), 1,
|
|
426
|
+
makeCanceller()));
|
|
427
|
+
global.setProperty(
|
|
428
|
+
rt, "requestAnimationFrame",
|
|
429
|
+
jsi::Function::createFromHostFunction(
|
|
430
|
+
rt, jsi::PropNameID::forAscii(rt, "requestAnimationFrame"), 1,
|
|
431
|
+
rafFn));
|
|
432
|
+
global.setProperty(
|
|
433
|
+
rt, "cancelAnimationFrame",
|
|
434
|
+
jsi::Function::createFromHostFunction(
|
|
435
|
+
rt, jsi::PropNameID::forAscii(rt, "cancelAnimationFrame"), 1,
|
|
436
|
+
makeCanceller()));
|
|
437
|
+
global.setProperty(
|
|
438
|
+
rt, "requestIdleCallback",
|
|
439
|
+
jsi::Function::createFromHostFunction(
|
|
440
|
+
rt, jsi::PropNameID::forAscii(rt, "requestIdleCallback"), 1,
|
|
441
|
+
ricFn));
|
|
442
|
+
global.setProperty(
|
|
443
|
+
rt, "cancelIdleCallback",
|
|
444
|
+
jsi::Function::createFromHostFunction(
|
|
445
|
+
rt, jsi::PropNameID::forAscii(rt, "cancelIdleCallback"), 1,
|
|
446
|
+
makeCanceller()));
|
|
447
|
+
LOGI("Timer + rAF + rIC polyfills installed on bg runtime");
|
|
89
448
|
}
|
|
90
449
|
|
|
91
450
|
// ── nativeInstallSharedBridge ───────────────────────────────────────────
|
|
@@ -145,9 +504,20 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge(
|
|
|
145
504
|
};
|
|
146
505
|
|
|
147
506
|
std::string runtimeId = isMain ? "main" : "background";
|
|
507
|
+
// Save the bg executor so our custom timer worker can dispatch callbacks
|
|
508
|
+
// back to the bg JS queue. We must do this BEFORE moving `executor` into
|
|
509
|
+
// SharedRPC::install (which will std::move it out).
|
|
510
|
+
if (!capturedIsMain) {
|
|
511
|
+
gBgTimerExecutor = executor;
|
|
512
|
+
}
|
|
148
513
|
SharedRPC::install(*rt, std::move(executor), runtimeId);
|
|
149
514
|
LOGI("SharedStore and SharedRPC installed (isMain=%d)", static_cast<int>(isMain));
|
|
150
515
|
if (!capturedIsMain) {
|
|
516
|
+
// Install setTimeout/setInterval/clearTimeout/clearInterval on the
|
|
517
|
+
// background runtime. React Native's built-in timer module only wires
|
|
518
|
+
// into the main runtime, so without this, any `await wait(ms)` or
|
|
519
|
+
// setTimeout callback in the background thread would never fire.
|
|
520
|
+
installTimersOnRuntime(*rt);
|
|
151
521
|
invokeOptionalGlobalFunction(*rt, "__setupBackgroundRPCHandler");
|
|
152
522
|
}
|
|
153
523
|
}
|
|
@@ -226,5 +596,41 @@ Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy(
|
|
|
226
596
|
SharedRPC::reset();
|
|
227
597
|
SharedStore::reset();
|
|
228
598
|
|
|
599
|
+
// Stop and join the timer worker before touching any shared state it
|
|
600
|
+
// reads. Without this, the worker could finish one last dispatch after
|
|
601
|
+
// we clear gTimers / gBgTimerExecutor and enqueue stale work against
|
|
602
|
+
// the about-to-be-destroyed runtime.
|
|
603
|
+
gTimerWorkerStop.store(true);
|
|
604
|
+
gTimerCv.notify_all();
|
|
605
|
+
if (gTimerWorkerThread.joinable()) {
|
|
606
|
+
gTimerWorkerThread.join();
|
|
607
|
+
}
|
|
608
|
+
gTimerWorkerStarted.store(false);
|
|
609
|
+
|
|
610
|
+
// Intentionally leak remaining jsi::Function callbacks (same rationale
|
|
611
|
+
// as SharedRPC::reset): destroying them here would run ~jsi::Function
|
|
612
|
+
// on the torn-down runtime and crash.
|
|
613
|
+
{
|
|
614
|
+
std::lock_guard<std::mutex> lock(gTimerMutex);
|
|
615
|
+
for (auto &entry : gTimers) {
|
|
616
|
+
if (entry.second.callback) {
|
|
617
|
+
new std::shared_ptr<jsi::Function>(std::move(entry.second.callback));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
gTimers.clear();
|
|
621
|
+
gBgTimerExecutor = nullptr;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Drain pending cross-runtime work. Each std::function may capture a
|
|
625
|
+
// shared_ptr<jsi::Function> tied to the destroyed runtime; leak them
|
|
626
|
+
// for the same reason as above.
|
|
627
|
+
{
|
|
628
|
+
std::lock_guard<std::mutex> lock(gWorkMutex);
|
|
629
|
+
for (auto &entry : gPendingWork) {
|
|
630
|
+
new std::function<void(jsi::Runtime &)>(std::move(entry.second));
|
|
631
|
+
}
|
|
632
|
+
gPendingWork.clear();
|
|
633
|
+
}
|
|
634
|
+
|
|
229
635
|
LOGI("Native resources cleaned up");
|
|
230
636
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
package com.backgroundthread
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
3
5
|
import android.net.Uri
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
4
8
|
import com.facebook.react.ReactPackage
|
|
5
9
|
import com.facebook.proguard.annotations.DoNotStrip
|
|
6
10
|
import com.facebook.react.ReactInstanceEventListener
|
|
@@ -16,6 +20,7 @@ import com.facebook.react.runtime.ReactHostImpl
|
|
|
16
20
|
import com.facebook.react.runtime.hermes.HermesInstance
|
|
17
21
|
import com.facebook.react.shell.MainReactPackage
|
|
18
22
|
import java.io.File
|
|
23
|
+
import java.lang.ref.WeakReference
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Singleton manager for the background React Native runtime.
|
|
@@ -31,6 +36,11 @@ class BackgroundThreadManager private constructor() {
|
|
|
31
36
|
private var bgReactHost: ReactHostImpl? = null
|
|
32
37
|
private var reactPackages: List<ReactPackage> = emptyList()
|
|
33
38
|
|
|
39
|
+
// Tracks the last resumed Activity so we can replay it onto the bg
|
|
40
|
+
// ReactContext as soon as the bg host finishes initializing (covers the
|
|
41
|
+
// cold-start race where Activity resumes before bg host is ready).
|
|
42
|
+
private var lastResumedActivityRef: WeakReference<Activity> = WeakReference(null)
|
|
43
|
+
|
|
34
44
|
@Volatile
|
|
35
45
|
private var bgRuntimePtr: Long = 0
|
|
36
46
|
|
|
@@ -329,6 +339,11 @@ class BackgroundThreadManager private constructor() {
|
|
|
329
339
|
override fun onReactContextInitialized(context: ReactContext) {
|
|
330
340
|
val initMs = (System.nanoTime() - bgStartTime) / 1_000_000.0
|
|
331
341
|
BTLogger.info("[SplitBundle] background ReactContext initialized in ${String.format("%.1f", initMs)}ms")
|
|
342
|
+
// Replay the most recent Activity resume so TurboModules on the
|
|
343
|
+
// bg host can see getCurrentActivity()/ActivityEventListeners
|
|
344
|
+
// from the very first call, even when the bg host finishes
|
|
345
|
+
// initializing after the Activity is already resumed.
|
|
346
|
+
replayLastResumedActivityOnUi()
|
|
332
347
|
context.runOnJSQueueThread {
|
|
333
348
|
try {
|
|
334
349
|
val ptr = context.javaScriptContextHolder?.get() ?: 0L
|
|
@@ -417,22 +432,328 @@ class BackgroundThreadManager private constructor() {
|
|
|
417
432
|
return
|
|
418
433
|
}
|
|
419
434
|
|
|
420
|
-
|
|
435
|
+
// Use ReactContext.registerSegment which works in both bridge
|
|
436
|
+
// and bridgeless modes.
|
|
437
|
+
try {
|
|
438
|
+
context.registerSegment(segmentId, path) {
|
|
439
|
+
BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path")
|
|
440
|
+
onComplete(null)
|
|
441
|
+
}
|
|
442
|
+
} catch (e: Exception) {
|
|
443
|
+
BTLogger.error("Failed to register segment in background runtime: ${e.message}")
|
|
444
|
+
onComplete(e)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Activity lifecycle bridge (selective, reflection-based) ─────────────
|
|
449
|
+
//
|
|
450
|
+
// Goal: let a small, explicit allowlist of native modules on the bg
|
|
451
|
+
// ReactHost observe Activity-related state (getCurrentActivity(),
|
|
452
|
+
// onActivityResult, onNewIntent, and — for the same allowlist —
|
|
453
|
+
// onHostResume/onHostPause/onHostDestroy) WITHOUT triggering any side
|
|
454
|
+
// effects on other modules that happen to live on the same ReactContext.
|
|
455
|
+
//
|
|
456
|
+
// Why not use ReactHost.onHostResume / onActivityResult directly?
|
|
457
|
+
// Those internally fan out to ALL LifecycleEventListeners and
|
|
458
|
+
// ActivityEventListeners registered on the ReactContext, which would
|
|
459
|
+
// cause every bg TurboModule that tracks host lifecycle (BroadcastReceivers,
|
|
460
|
+
// sensors, keyboard observers, etc.) to receive a second set of callbacks
|
|
461
|
+
// in addition to the UI host. That doubles resource registration, opens
|
|
462
|
+
// requestCode collisions on Activity results, and can tear down the bg
|
|
463
|
+
// ReactContext during rotation. We need fine-grained control, which RN
|
|
464
|
+
// does not expose, so we operate on the underlying fields directly.
|
|
465
|
+
//
|
|
466
|
+
// What we actually do:
|
|
467
|
+
// - Reflect-write bg ReactContext's `mCurrentActivity` so
|
|
468
|
+
// getCurrentActivity() returns the correct Activity for modules that
|
|
469
|
+
// bother to query it. Modules that don't query it are unaffected.
|
|
470
|
+
// - Read `mActivityEventListeners` via reflection and invoke ONLY the
|
|
471
|
+
// listeners whose class FQCN matches an entry in
|
|
472
|
+
// `bgActivityBridgeListenerClassAllowlist` (for onActivityResult/onNewIntent).
|
|
473
|
+
// - Read `mLifecycleEventListeners` via reflection and invoke
|
|
474
|
+
// onHostResume/onHostPause/onHostDestroy on listeners whose class
|
|
475
|
+
// FQCN matches the same allowlist. Non-allowlisted listeners are
|
|
476
|
+
// never fired on the bg host — preserving the pre-existing baseline
|
|
477
|
+
// that bg was "never resumed".
|
|
478
|
+
// - Deliberately do NOT touch `mLifecycleState` — setting it to RESUMED
|
|
479
|
+
// would cause `addLifecycleEventListener(...)` to auto-fire
|
|
480
|
+
// `onHostResume()` on EVERY newly-registered listener (including
|
|
481
|
+
// non-allowlisted ones), reintroducing the double-dispatch we're
|
|
482
|
+
// trying to avoid. Instead we fire onHostResume manually on each
|
|
483
|
+
// dispatchActivityResumed, which covers the common case; a listener
|
|
484
|
+
// registered strictly between two resume events will catch up on
|
|
485
|
+
// the next cycle.
|
|
486
|
+
//
|
|
487
|
+
// Trade-off:
|
|
488
|
+
// We rely on RN-internal field names `mCurrentActivity`,
|
|
489
|
+
// `mActivityEventListeners`, `mLifecycleEventListeners` on
|
|
490
|
+
// `com.facebook.react.bridge.ReactContext`. An RN upgrade that
|
|
491
|
+
// renames or restructures these fields will cause reflection to fail
|
|
492
|
+
// (caught and logged via BTLogger → OneKeyLog). Mitigate with a
|
|
493
|
+
// dev-build smoke assertion at the call site.
|
|
494
|
+
//
|
|
495
|
+
// Thread safety:
|
|
496
|
+
// ReactContext.onHostResume / onActivityResult are @ThreadConfined(UI);
|
|
497
|
+
// we match that by bouncing to the main looper. The underlying sets
|
|
498
|
+
// are CopyOnWriteArraySet, so iterating them off-thread would still be
|
|
499
|
+
// safe, but UI-thread is the documented contract and we stick to it.
|
|
500
|
+
|
|
501
|
+
// ── Allowlist (registered externally by the host app) ───────────────────
|
|
502
|
+
//
|
|
503
|
+
// FQCN prefixes of ActivityEventListener / LifecycleEventListener
|
|
504
|
+
// implementations that are allowed to receive Activity-bound events
|
|
505
|
+
// (onActivityResult, onNewIntent, onHostResume/Pause/Destroy) on the
|
|
506
|
+
// bg ReactHost. Anything not matching a registered prefix is fully
|
|
507
|
+
// ignored on bg, preserving the pre-existing baseline.
|
|
508
|
+
//
|
|
509
|
+
// The bg-thread module deliberately ships an EMPTY default — the host
|
|
510
|
+
// application is the only place that knows which third-party modules
|
|
511
|
+
// are bg-eligible. Register entries early in Application.onCreate via
|
|
512
|
+
// addBgActivityBridgeListenerClassPrefix(...) so the allowlist is populated
|
|
513
|
+
// before the first Activity lifecycle callback can fire.
|
|
514
|
+
//
|
|
515
|
+
// Adding a prefix is a cross-runtime change. Verify:
|
|
516
|
+
// 1. The module's listener is idempotent and safe to fire.
|
|
517
|
+
// 2. ActivityResult requestCodes do not collide with other modules.
|
|
518
|
+
// 3. Any LifecycleEventListener side-effects are double-host safe.
|
|
519
|
+
@Volatile
|
|
520
|
+
private var bgActivityBridgeListenerClassAllowlist: Set<String> = emptySet()
|
|
521
|
+
|
|
522
|
+
/** Add a single FQCN-prefix entry to the allowlist (idempotent). */
|
|
523
|
+
@Synchronized
|
|
524
|
+
fun addBgActivityBridgeListenerClassPrefix(prefix: String) {
|
|
525
|
+
if (prefix.isEmpty()) return
|
|
526
|
+
if (bgActivityBridgeListenerClassAllowlist.contains(prefix)) return
|
|
527
|
+
bgActivityBridgeListenerClassAllowlist = bgActivityBridgeListenerClassAllowlist + prefix
|
|
528
|
+
BTLogger.info("addBgActivityBridgeListenerClassPrefix: $prefix " +
|
|
529
|
+
"(total=${bgActivityBridgeListenerClassAllowlist.size})")
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Replace the allowlist wholesale. Intended for test setup / reload. */
|
|
533
|
+
@Synchronized
|
|
534
|
+
fun setBgActivityBridgeListenerClassAllowlist(prefixes: Set<String>) {
|
|
535
|
+
bgActivityBridgeListenerClassAllowlist = prefixes.toSet()
|
|
536
|
+
BTLogger.info("setBgActivityBridgeListenerClassAllowlist: ${prefixes.size} entries")
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Inspect the current allowlist (snapshot). */
|
|
540
|
+
fun getBgActivityBridgeListenerClassAllowlist(): Set<String> =
|
|
541
|
+
bgActivityBridgeListenerClassAllowlist
|
|
542
|
+
|
|
543
|
+
fun dispatchActivityResumed(activity: Activity) {
|
|
544
|
+
lastResumedActivityRef = WeakReference(activity)
|
|
545
|
+
runOnUiThread {
|
|
546
|
+
writeBgCurrentActivity(activity)
|
|
547
|
+
dispatchLifecycleEventToAllowlisted(LifecycleEvent.RESUME)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
fun dispatchActivityPaused(activity: Activity) {
|
|
552
|
+
// Keep mCurrentActivity — Activity is still valid until destroyed,
|
|
553
|
+
// and clearing it between pause/resume would flap getCurrentActivity().
|
|
554
|
+
// LifecycleEventListener.onHostPause() IS fired for allowlisted
|
|
555
|
+
// modules so they can quiesce work (mirrors RN's UI-host behaviour).
|
|
556
|
+
runOnUiThread {
|
|
557
|
+
dispatchLifecycleEventToAllowlisted(LifecycleEvent.PAUSE)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
fun dispatchActivityDestroyed(activity: Activity) {
|
|
562
|
+
if (lastResumedActivityRef.get() === activity) {
|
|
563
|
+
lastResumedActivityRef = WeakReference(null)
|
|
564
|
+
}
|
|
565
|
+
runOnUiThread {
|
|
566
|
+
val ctx = bgReactHost?.currentReactContext ?: return@runOnUiThread
|
|
567
|
+
// Only clear if the bg context's tracked Activity IS the one
|
|
568
|
+
// being destroyed. Guards against a stale clear when the host
|
|
569
|
+
// Activity is swapped (e.g. multi-Activity deep-link flows).
|
|
570
|
+
if (ctx.currentActivity === activity) {
|
|
571
|
+
writeBgCurrentActivity(null)
|
|
572
|
+
dispatchLifecycleEventToAllowlisted(LifecycleEvent.DESTROY)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private enum class LifecycleEvent { RESUME, PAUSE, DESTROY }
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Iterate bg ReactContext's LifecycleEventListener set and fire the
|
|
581
|
+
* requested callback on listeners whose class FQCN matches the
|
|
582
|
+
* allowlist. Modules outside the allowlist are completely unaffected
|
|
583
|
+
* (they stay on the pre-existing "never-resumed on bg" baseline).
|
|
584
|
+
*
|
|
585
|
+
* Note on edge case: a LifecycleEventListener registered AFTER the
|
|
586
|
+
* corresponding dispatch event will miss that event, because we don't
|
|
587
|
+
* touch mLifecycleState (doing so would cause RN's
|
|
588
|
+
* addLifecycleEventListener to auto-fire onHostResume on EVERY new
|
|
589
|
+
* listener, including non-allowlisted ones). The next resume/pause/
|
|
590
|
+
* destroy cycle picks it up. This is consistent with how modules that
|
|
591
|
+
* register late behave against the UI host anyway.
|
|
592
|
+
*/
|
|
593
|
+
private fun dispatchLifecycleEventToAllowlisted(event: LifecycleEvent) {
|
|
594
|
+
val listeners = readBgLifecycleListeners() ?: return
|
|
595
|
+
for (l in listeners) {
|
|
596
|
+
if (!isBgListenerAllowed(l)) continue
|
|
421
597
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
598
|
+
when (event) {
|
|
599
|
+
LifecycleEvent.RESUME -> l.onHostResume()
|
|
600
|
+
LifecycleEvent.PAUSE -> l.onHostPause()
|
|
601
|
+
LifecycleEvent.DESTROY -> l.onHostDestroy()
|
|
602
|
+
}
|
|
603
|
+
} catch (t: Throwable) {
|
|
604
|
+
BTLogger.error("bg lifecycle ${event.name} dispatch (${l.javaClass.name}): ${t.message}")
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
fun dispatchActivityResult(
|
|
610
|
+
activity: Activity,
|
|
611
|
+
requestCode: Int,
|
|
612
|
+
resultCode: Int,
|
|
613
|
+
data: Intent?
|
|
614
|
+
) {
|
|
615
|
+
runOnUiThread {
|
|
616
|
+
val listeners = readBgActivityListeners() ?: return@runOnUiThread
|
|
617
|
+
for (l in listeners) {
|
|
618
|
+
if (!isBgListenerAllowed(l)) continue
|
|
619
|
+
try {
|
|
620
|
+
l.onActivityResult(activity, requestCode, resultCode, data)
|
|
621
|
+
} catch (t: Throwable) {
|
|
622
|
+
BTLogger.error("bg onActivityResult dispatch (${l.javaClass.name}): ${t.message}")
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fun dispatchNewIntent(intent: Intent) {
|
|
629
|
+
runOnUiThread {
|
|
630
|
+
val listeners = readBgActivityListeners() ?: return@runOnUiThread
|
|
631
|
+
for (l in listeners) {
|
|
632
|
+
if (!isBgListenerAllowed(l)) continue
|
|
633
|
+
try {
|
|
634
|
+
l.onNewIntent(intent)
|
|
635
|
+
} catch (t: Throwable) {
|
|
636
|
+
BTLogger.error("bg onNewIntent dispatch (${l.javaClass.name}): ${t.message}")
|
|
428
637
|
}
|
|
429
|
-
} catch (e: Exception) {
|
|
430
|
-
BTLogger.error("Failed to register segment in background runtime: ${e.message}")
|
|
431
|
-
onComplete(e)
|
|
432
638
|
}
|
|
433
639
|
}
|
|
434
640
|
}
|
|
435
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Called when the bg ReactContext becomes available so we can install
|
|
644
|
+
* the last resumed Activity right away, covering the window where the
|
|
645
|
+
* host Activity resumes before the bg host finishes initializing.
|
|
646
|
+
*/
|
|
647
|
+
private fun replayLastResumedActivityOnUi() {
|
|
648
|
+
val activity = lastResumedActivityRef.get() ?: return
|
|
649
|
+
runOnUiThread {
|
|
650
|
+
if (writeBgCurrentActivity(activity)) {
|
|
651
|
+
BTLogger.info("replayLastResumedActivityOnUi: mCurrentActivity=${activity.javaClass.simpleName}")
|
|
652
|
+
}
|
|
653
|
+
// Fire RESUME for allowlisted modules whose listener was
|
|
654
|
+
// registered before the bg ReactContext finished initializing.
|
|
655
|
+
dispatchLifecycleEventToAllowlisted(LifecycleEvent.RESUME)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Reflect-write `mCurrentActivity` on the bg ReactContext so
|
|
661
|
+
* TurboModules on that host see a non-null getCurrentActivity().
|
|
662
|
+
*
|
|
663
|
+
* Returns true on success.
|
|
664
|
+
*/
|
|
665
|
+
private fun writeBgCurrentActivity(activity: Activity?): Boolean {
|
|
666
|
+
val ctx = bgReactHost?.currentReactContext ?: return false
|
|
667
|
+
return try {
|
|
668
|
+
val field = findField(ctx.javaClass, "mCurrentActivity") ?: run {
|
|
669
|
+
BTLogger.error("writeBgCurrentActivity: mCurrentActivity field not found (RN upgrade?)")
|
|
670
|
+
return false
|
|
671
|
+
}
|
|
672
|
+
field.isAccessible = true
|
|
673
|
+
field.set(ctx, if (activity != null) WeakReference(activity) else null)
|
|
674
|
+
true
|
|
675
|
+
} catch (t: Throwable) {
|
|
676
|
+
BTLogger.error("writeBgCurrentActivity: ${t.message}")
|
|
677
|
+
false
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Reflect-read bg ReactContext's `mActivityEventListeners` set so we
|
|
683
|
+
* can iterate it without going through ReactContext.onActivityResult
|
|
684
|
+
* (which would fan out to every listener unconditionally).
|
|
685
|
+
*/
|
|
686
|
+
@Suppress("UNCHECKED_CAST")
|
|
687
|
+
private fun readBgActivityListeners(): Collection<com.facebook.react.bridge.ActivityEventListener>? {
|
|
688
|
+
val ctx = bgReactHost?.currentReactContext ?: return null
|
|
689
|
+
return try {
|
|
690
|
+
val field = findField(ctx.javaClass, "mActivityEventListeners") ?: run {
|
|
691
|
+
BTLogger.error("readBgActivityListeners: field not found (RN upgrade?)")
|
|
692
|
+
return null
|
|
693
|
+
}
|
|
694
|
+
field.isAccessible = true
|
|
695
|
+
field.get(ctx) as? Collection<com.facebook.react.bridge.ActivityEventListener>
|
|
696
|
+
} catch (t: Throwable) {
|
|
697
|
+
BTLogger.error("readBgActivityListeners: ${t.message}")
|
|
698
|
+
null
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Reflect-read bg ReactContext's `mLifecycleEventListeners` set. Same
|
|
704
|
+
* rationale as readBgActivityListeners — we iterate ourselves so we
|
|
705
|
+
* can apply the allowlist filter, avoiding a fan-out to every
|
|
706
|
+
* LifecycleEventListener on the bg ReactContext.
|
|
707
|
+
*/
|
|
708
|
+
@Suppress("UNCHECKED_CAST")
|
|
709
|
+
private fun readBgLifecycleListeners(): Collection<com.facebook.react.bridge.LifecycleEventListener>? {
|
|
710
|
+
val ctx = bgReactHost?.currentReactContext ?: return null
|
|
711
|
+
return try {
|
|
712
|
+
val field = findField(ctx.javaClass, "mLifecycleEventListeners") ?: run {
|
|
713
|
+
BTLogger.error("readBgLifecycleListeners: field not found (RN upgrade?)")
|
|
714
|
+
return null
|
|
715
|
+
}
|
|
716
|
+
field.isAccessible = true
|
|
717
|
+
field.get(ctx) as? Collection<com.facebook.react.bridge.LifecycleEventListener>
|
|
718
|
+
} catch (t: Throwable) {
|
|
719
|
+
BTLogger.error("readBgLifecycleListeners: ${t.message}")
|
|
720
|
+
null
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Walks the class hierarchy to find a declared field by name.
|
|
726
|
+
* `mCurrentActivity` and `mActivityEventListeners` live on
|
|
727
|
+
* `ReactContext`, but bg runs `BridgelessReactContext` which subclasses
|
|
728
|
+
* `ReactApplicationContext` which subclasses `ReactContext`, so we
|
|
729
|
+
* can't use getDeclaredField directly on the runtime class.
|
|
730
|
+
*/
|
|
731
|
+
private fun findField(cls: Class<*>, name: String): java.lang.reflect.Field? {
|
|
732
|
+
var c: Class<*>? = cls
|
|
733
|
+
while (c != null) {
|
|
734
|
+
try { return c.getDeclaredField(name) } catch (_: NoSuchFieldException) {}
|
|
735
|
+
c = c.superclass
|
|
736
|
+
}
|
|
737
|
+
return null
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private fun isBgListenerAllowed(listener: Any): Boolean {
|
|
741
|
+
val fqcn = listener.javaClass.name
|
|
742
|
+
val list = bgActivityBridgeListenerClassAllowlist
|
|
743
|
+
for (prefix in list) {
|
|
744
|
+
if (fqcn.startsWith(prefix)) return true
|
|
745
|
+
}
|
|
746
|
+
return false
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private fun runOnUiThread(block: () -> Unit) {
|
|
750
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
751
|
+
block()
|
|
752
|
+
} else {
|
|
753
|
+
Handler(Looper.getMainLooper()).post(block)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
436
757
|
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
437
758
|
|
|
438
759
|
val isBackgroundStarted: Boolean get() = isStarted
|
|
@@ -445,5 +766,6 @@ class BackgroundThreadManager private constructor() {
|
|
|
445
766
|
bgReactHost?.destroy("BackgroundThreadManager destroyed", null)
|
|
446
767
|
bgReactHost = null
|
|
447
768
|
isStarted = false
|
|
769
|
+
lastResumedActivityRef = WeakReference(null)
|
|
448
770
|
}
|
|
449
771
|
}
|
|
@@ -107,7 +107,12 @@ static NSString *const MODULE_DEBUG_URL = @"http://localhost:8082/apps/mobile/ba
|
|
|
107
107
|
self.reactNativeFactoryDelegate = [[BackgroundReactNativeDelegate alloc] init];
|
|
108
108
|
self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeFactoryDelegate];
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
// Only set jsBundleSource for debug HTTP URLs or explicit OTA overrides.
|
|
111
|
+
// Leaving the default release name ("background.bundle") unset lets the
|
|
112
|
+
// delegate fall back to split-bundle mode (common.jsbundle + entry).
|
|
113
|
+
if (![entryURL isEqualToString:@"background.bundle"]) {
|
|
114
|
+
[self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])];
|
|
115
|
+
}
|
|
111
116
|
|
|
112
117
|
[self.reactNativeFactory.rootViewFactory viewWithModuleName:MODULE_NAME
|
|
113
118
|
initialProperties:initialProperties
|