@poolse/react-native-calls 0.4.0 → 0.4.1

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.
@@ -17,18 +17,25 @@ public class PoolseCallsModule: Module {
17
17
  PoolseCallsSetup.registerVoipEarly()
18
18
  }
19
19
 
20
+ // The listeners are process-level statics read on main: assign and
21
+ // clear them on main too (these closures run on the module's queue).
20
22
  OnStartObserving {
21
- CallCore.listener = { [weak self] snapshot in
22
- self?.sendEvent("onCallState", ["snapshot": snapshot ?? NSNull()])
23
- }
24
- VoipPush.tokenListener = { [weak self] token in
25
- self?.sendEvent("onVoipToken", ["token": token, "environment": PoolseCallsModule.environment()])
23
+ DispatchQueue.main.async { [weak self] in
24
+ guard self != nil else { return }
25
+ CallCore.listener = { [weak self] snapshot in
26
+ self?.sendEvent("onCallState", ["snapshot": snapshot ?? NSNull()])
27
+ }
28
+ VoipPush.tokenListener = { [weak self] token in
29
+ self?.sendEvent("onVoipToken", ["token": token, "environment": PoolseCallsModule.environment()])
30
+ }
26
31
  }
27
32
  }
28
33
 
29
34
  OnStopObserving {
30
- CallCore.listener = nil
31
- VoipPush.tokenListener = nil
35
+ DispatchQueue.main.async {
36
+ CallCore.listener = nil
37
+ VoipPush.tokenListener = nil
38
+ }
32
39
  }
33
40
 
34
41
  AsyncFunction("configure") { (apiBaseUrl: String, options: [String: Any]?) in
@@ -45,6 +45,7 @@ final class CallCore {
45
45
  defaults.set(trimmed, forKey: "apiBaseUrl")
46
46
  defaults.set(ringback, forKey: "ringback")
47
47
  defaults.set(apnsEnvironment, forKey: "apnsEnvironment")
48
+ writeConfigFile(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
48
49
  configLock.lock()
49
50
  _config = CallCoreConfig(apiBaseUrl: trimmed, ringback: ringback, apnsEnvironment: apnsEnvironment)
50
51
  configLock.unlock()
@@ -52,18 +53,65 @@ final class CallCore {
52
53
 
53
54
  /// The APNs environment last configured (persisted), if any.
54
55
  static var apnsEnvironment: String? {
55
- config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment")
56
+ config?.apnsEnvironment ?? defaults.string(forKey: "apnsEnvironment") ?? readConfigFile()?.apnsEnvironment
56
57
  }
57
58
 
58
59
  private static func restoreConfig() {
59
- guard config == nil, let url = defaults.string(forKey: "apiBaseUrl") else { return }
60
+ guard config == nil else { return }
61
+ let restored: CallCoreConfig
62
+ if let url = defaults.string(forKey: "apiBaseUrl") {
63
+ restored = CallCoreConfig(apiBaseUrl: url,
64
+ ringback: defaults.string(forKey: "ringback"),
65
+ apnsEnvironment: defaults.string(forKey: "apnsEnvironment"))
66
+ } else if let fromFile = readConfigFile() {
67
+ // Locked since reboot: UserDefaults is unreadable before first
68
+ // unlock, the unprotected file is not.
69
+ restored = fromFile
70
+ } else {
71
+ return
72
+ }
60
73
  configLock.lock()
61
- _config = CallCoreConfig(apiBaseUrl: url,
62
- ringback: defaults.string(forKey: "ringback"),
63
- apnsEnvironment: defaults.string(forKey: "apnsEnvironment"))
74
+ _config = restored
64
75
  configLock.unlock()
65
76
  }
66
77
 
78
+ // MARK: config file (readable before first unlock)
79
+
80
+ /// Application Support/dev.poolse.callcore/config.json, written with
81
+ /// FileProtectionType.none so a PushKit wake while the device has been
82
+ /// locked since reboot can still rebuild the core. Holds no secrets —
83
+ /// only the API origin, a sound name and the APNs environment.
84
+ private static var configFileURL: URL? {
85
+ guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
86
+ return base.appendingPathComponent("dev.poolse.callcore", isDirectory: true).appendingPathComponent("config.json")
87
+ }
88
+
89
+ private static func writeConfigFile(apiBaseUrl: String, ringback: String?, apnsEnvironment: String?) {
90
+ guard let url = configFileURL else { return }
91
+ var obj: [String: Any] = ["apiBaseUrl": apiBaseUrl]
92
+ if let ringback = ringback { obj["ringback"] = ringback }
93
+ if let apnsEnvironment = apnsEnvironment { obj["apnsEnvironment"] = apnsEnvironment }
94
+ do {
95
+ let fm = FileManager.default
96
+ let dir = url.deletingLastPathComponent()
97
+ try fm.createDirectory(at: dir, withIntermediateDirectories: true,
98
+ attributes: [.protectionKey: FileProtectionType.none])
99
+ let data = try JSONSerialization.data(withJSONObject: obj)
100
+ try data.write(to: url, options: [.atomic, .noFileProtection])
101
+ try? fm.setAttributes([.protectionKey: FileProtectionType.none], ofItemAtPath: url.path)
102
+ } catch {
103
+ NSLog("[PoolseCalls] config file write failed: %@", "\(error)")
104
+ }
105
+ }
106
+
107
+ private static func readConfigFile() -> CallCoreConfig? {
108
+ guard let url = configFileURL, let data = try? Data(contentsOf: url),
109
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
110
+ let api = obj["apiBaseUrl"] as? String, !api.isEmpty else { return nil }
111
+ return CallCoreConfig(apiBaseUrl: api, ringback: obj["ringback"] as? String,
112
+ apnsEnvironment: obj["apnsEnvironment"] as? String)
113
+ }
114
+
67
115
  /// The core, or nil if this install was never configured. Main thread.
68
116
  static func getOrNull() -> CallCore? {
69
117
  if let i = instance { return i }
@@ -89,7 +89,7 @@ final class CallKitController: NSObject, CXProviderDelegate {
89
89
  func reportIncoming(ring: RingContext) {
90
90
  let hasVideo = ring.media == "video"
91
91
  let title = ring.conversationName ?? ring.callerName
92
- if let existing = calls.values.first(where: { $0.callId == ring.callId }) {
92
+ if let existing = calls.values.first(where: { $0.callId == ring.callId && !$0.ended }) {
93
93
  provider.reportNewIncomingCall(with: existing.uuid, update: update(title: title, handle: ring.conversationId, video: hasVideo)) { _ in }
94
94
  return
95
95
  }
@@ -99,8 +99,13 @@ final class CallKitController: NSObject, CXProviderDelegate {
99
99
  guard let error = error else { return }
100
100
  NSLog("[PoolseCalls] CallKit refused incoming call %@: %@", ring.callId, error.localizedDescription)
101
101
  // Refused (DND, blocked, busy group): CallKit holds nothing for it.
102
- t.ended = true
103
- self?.calls.removeValue(forKey: t.uuid)
102
+ // End the ring in the core too — silently (no decline is sent), so
103
+ // the engine is not left RINGING_IN with no UI, counting as busy.
104
+ DispatchQueue.main.async {
105
+ t.ended = true
106
+ self?.calls.removeValue(forKey: t.uuid)
107
+ CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
108
+ }
104
109
  }
105
110
  }
106
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
5
5
  "license": "MIT",
6
6
  "type": "module",