capacitor-mobilecron 0.2.21 → 0.2.23

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/Package.swift ADDED
@@ -0,0 +1,27 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "CapacitorMobilecron",
6
+ platforms: [.iOS(.v14)],
7
+ products: [
8
+ .library(
9
+ name: "CapacitorMobilecron",
10
+ targets: ["MobileCronPlugin"]
11
+ )
12
+ ],
13
+ dependencies: [
14
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
15
+ ],
16
+ targets: [
17
+ .target(
18
+ name: "MobileCronPlugin",
19
+ dependencies: [
20
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
21
+ .product(name: "Cordova", package: "capacitor-swift-pm")
22
+ ],
23
+ path: "ios/Plugin",
24
+ exclude: ["MobileCronPlugin.m"]
25
+ )
26
+ ]
27
+ )
@@ -3,6 +3,8 @@ package io.mobilecron
3
3
  import android.content.Context
4
4
  import android.content.Intent
5
5
  import android.content.IntentFilter
6
+ import android.os.Handler
7
+ import android.os.Looper
6
8
  import androidx.work.Constraints
7
9
  import androidx.work.ExistingPeriodicWorkPolicy
8
10
  import androidx.work.ExistingWorkPolicy
@@ -29,27 +31,55 @@ class MobileCronPlugin : Plugin() {
29
31
  private var mode = "balanced"
30
32
  private var chargingReceiver: ChargingReceiver? = null
31
33
 
34
+ // Foreground watchdog — evaluates due jobs while the app is active
35
+ private val handler = Handler(Looper.getMainLooper())
36
+ private var watchdogRunning = false
37
+ private val watchdogRunnable = object : Runnable {
38
+ override fun run() {
39
+ if (!watchdogRunning) return
40
+ evaluateDueJobsForeground()
41
+ handler.postDelayed(this, tickMs())
42
+ }
43
+ }
44
+
32
45
  companion object {
33
46
  private const val STORAGE_FILE = "CapacitorStorage"
34
47
  private const val STORAGE_KEY = "mobilecron:state"
48
+ private const val TICK_ECO = 60_000L
49
+ private const val TICK_BALANCED = 30_000L
50
+ private const val TICK_AGGRESSIVE = 15_000L
51
+ }
52
+
53
+ private fun tickMs(): Long = when (mode) {
54
+ "eco" -> TICK_ECO
55
+ "aggressive" -> TICK_AGGRESSIVE
56
+ else -> TICK_BALANCED
35
57
  }
36
58
 
37
59
  override fun load() {
38
60
  super.load()
39
61
  CronBridge.plugin = this
40
62
  loadState()
63
+ computeMissingNextDueAt()
41
64
  registerChargingReceiver()
42
65
  scheduleWorkManager()
66
+ startWatchdog()
43
67
  }
44
68
 
45
69
  override fun handleOnResume() {
46
70
  super.handleOnResume()
47
- // Fire any pendingNativeEvents written by NativeJobEvaluator while the app was backgrounded/killed.
48
71
  firePendingNativeEvents()
72
+ startWatchdog()
73
+ }
74
+
75
+ override fun handleOnPause() {
76
+ super.handleOnPause()
77
+ stopWatchdog()
49
78
  }
50
79
 
51
80
  override fun handleOnDestroy() {
52
81
  super.handleOnDestroy()
82
+ stopWatchdog()
53
83
  if (CronBridge.plugin === this) {
54
84
  CronBridge.plugin = null
55
85
  }
@@ -59,6 +89,115 @@ class MobileCronPlugin : Plugin() {
59
89
  chargingReceiver = null
60
90
  }
61
91
 
92
+ // ── Foreground watchdog ───────────────────────────────────────────────────
93
+
94
+ private fun startWatchdog() {
95
+ if (watchdogRunning) return
96
+ watchdogRunning = true
97
+ handler.postDelayed(watchdogRunnable, tickMs())
98
+ }
99
+
100
+ private fun stopWatchdog() {
101
+ watchdogRunning = false
102
+ handler.removeCallbacks(watchdogRunnable)
103
+ }
104
+
105
+ private fun restartWatchdog() {
106
+ stopWatchdog()
107
+ startWatchdog()
108
+ }
109
+
110
+ /**
111
+ * Evaluate due jobs while the app is in the foreground.
112
+ * Directly emits jobDue events via notifyListeners (no SharedPreferences round-trip).
113
+ * Mirrors the logic in NativeJobEvaluator but operates on the in-memory jobs map.
114
+ */
115
+ private fun evaluateDueJobsForeground() {
116
+ val now = System.currentTimeMillis()
117
+ var mutated = false
118
+
119
+ for ((id, job) in jobs) {
120
+ if (!job.optBoolean("enabled", false)) continue
121
+
122
+ // Compute nextDueAt if missing
123
+ val schedule = try { job.getJSONObject("schedule") } catch (_: Exception) { continue }
124
+ var nextDueAt = readLong(job.opt("nextDueAt"))
125
+ if (nextDueAt == null) {
126
+ nextDueAt = NativeJobEvaluator.computeNextDueAt(schedule, now)
127
+ if (nextDueAt != null) {
128
+ job.put("nextDueAt", nextDueAt)
129
+ mutated = true
130
+ }
131
+ }
132
+
133
+ if (nextDueAt == null || nextDueAt > now) continue
134
+
135
+ // Check skip reasons
136
+ if (paused) continue
137
+
138
+ val activeHours = try { job.getJSONObject("activeHours") } catch (_: Exception) { null }
139
+ if (activeHours != null && !NativeJobEvaluator.isWithinActiveHours(activeHours, now)) {
140
+ job.put("consecutiveSkips", job.optInt("consecutiveSkips", 0) + 1)
141
+ job.put("updatedAt", now)
142
+ if (schedule.optString("kind") == "every") {
143
+ val next = NativeJobEvaluator.computeNextDueAt(schedule, now)
144
+ if (next != null) job.put("nextDueAt", next) else job.remove("nextDueAt")
145
+ }
146
+ mutated = true
147
+ continue
148
+ }
149
+
150
+ // Fire the job
151
+ job.put("lastFiredAt", now)
152
+ job.put("updatedAt", now)
153
+ job.put("consecutiveSkips", 0)
154
+
155
+ if (schedule.optString("kind") == "at") {
156
+ job.put("enabled", false)
157
+ job.remove("nextDueAt")
158
+ } else {
159
+ val next = NativeJobEvaluator.computeNextDueAt(schedule, now)
160
+ if (next != null) job.put("nextDueAt", next) else job.remove("nextDueAt")
161
+ }
162
+
163
+ mutated = true
164
+
165
+ // Emit jobDue directly to JS listeners
166
+ val payload = JSObject()
167
+ payload.put("id", id)
168
+ payload.put("name", job.optString("name"))
169
+ payload.put("firedAt", now)
170
+ payload.put("source", "watchdog")
171
+ if (job.has("data")) {
172
+ try { payload.put("data", job.getJSONObject("data")) } catch (_: Exception) {}
173
+ }
174
+ notifyListeners("jobDue", payload)
175
+ }
176
+
177
+ if (mutated) {
178
+ saveState()
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Compute nextDueAt for all jobs that are missing it (e.g. after loadState from storage).
184
+ */
185
+ private fun computeMissingNextDueAt() {
186
+ val now = System.currentTimeMillis()
187
+ var mutated = false
188
+ for ((_, job) in jobs) {
189
+ if (!job.optBoolean("enabled", false)) continue
190
+ if (readLong(job.opt("nextDueAt")) != null) continue
191
+ val schedule = try { job.getJSONObject("schedule") } catch (_: Exception) { continue }
192
+ val computed = NativeJobEvaluator.computeNextDueAt(schedule, now)
193
+ if (computed != null) {
194
+ job.put("nextDueAt", computed)
195
+ mutated = true
196
+ }
197
+ }
198
+ if (mutated) saveState()
199
+ }
200
+
62
201
  // ── Persistence ─────────────────────────────────────────────────────────
63
202
 
64
203
  private fun loadState() {
@@ -200,17 +339,29 @@ class MobileCronPlugin : Plugin() {
200
339
  }
201
340
 
202
341
  val id = UUID.randomUUID().toString()
342
+ val now = System.currentTimeMillis()
343
+ val schedule = call.getObject("schedule") ?: JSObject()
344
+
203
345
  val record = JSObject()
204
346
  record.put("id", id)
205
347
  record.put("name", name)
206
348
  record.put("enabled", true)
207
- record.put("schedule", call.getObject("schedule") ?: JSObject())
349
+ record.put("schedule", schedule)
208
350
  record.put("activeHours", call.getObject("activeHours"))
209
351
  record.put("requiresNetwork", call.getBoolean("requiresNetwork", false))
210
352
  record.put("requiresCharging", call.getBoolean("requiresCharging", false))
211
353
  record.put("priority", call.getString("priority", "normal"))
212
354
  call.getObject("data")?.let { record.put("data", it) }
213
355
  record.put("consecutiveSkips", 0)
356
+ record.put("createdAt", now)
357
+ record.put("updatedAt", now)
358
+
359
+ // Compute nextDueAt at registration time
360
+ val nextDueAt = NativeJobEvaluator.computeNextDueAt(schedule, now)
361
+ if (nextDueAt != null) {
362
+ record.put("nextDueAt", nextDueAt)
363
+ }
364
+
214
365
  jobs[id] = record
215
366
  saveState()
216
367
 
@@ -247,12 +398,18 @@ class MobileCronPlugin : Plugin() {
247
398
  }
248
399
 
249
400
  call.getString("name")?.let { existing.put("name", it) }
250
- call.getObject("schedule")?.let { existing.put("schedule", it) }
401
+ call.getObject("schedule")?.let {
402
+ existing.put("schedule", it)
403
+ // Recompute nextDueAt when schedule changes
404
+ val next = NativeJobEvaluator.computeNextDueAt(it, System.currentTimeMillis())
405
+ if (next != null) existing.put("nextDueAt", next) else existing.remove("nextDueAt")
406
+ }
251
407
  if (call.data.has("activeHours")) existing.put("activeHours", call.getObject("activeHours"))
252
408
  if (call.data.has("requiresNetwork")) existing.put("requiresNetwork", call.getBoolean("requiresNetwork", false))
253
409
  if (call.data.has("requiresCharging")) existing.put("requiresCharging", call.getBoolean("requiresCharging", false))
254
410
  call.getString("priority")?.let { existing.put("priority", it) }
255
411
  if (call.data.has("data")) existing.put("data", call.getObject("data"))
412
+ existing.put("updatedAt", System.currentTimeMillis())
256
413
 
257
414
  jobs[id] = existing
258
415
  saveState()
@@ -318,6 +475,7 @@ class MobileCronPlugin : Plugin() {
318
475
  mode = next!!
319
476
  scheduleWorkManager()
320
477
  saveState()
478
+ restartWatchdog()
321
479
  call.resolve()
322
480
  notifyListeners("statusChanged", buildStatus())
323
481
  }
@@ -339,4 +497,13 @@ class MobileCronPlugin : Plugin() {
339
497
  })
340
498
  return status
341
499
  }
500
+
501
+ private fun readLong(value: Any?): Long? {
502
+ return when (value) {
503
+ null -> null
504
+ is Number -> value.toLong()
505
+ is String -> value.toLongOrNull()
506
+ else -> null
507
+ }
508
+ }
342
509
  }
package/dist/.gitkeep ADDED
File without changes
@@ -28,11 +28,23 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
28
28
  var currentMode: String { mode }
29
29
  private var bgManager: BGTaskManager?
30
30
 
31
+ // Foreground watchdog — evaluates due jobs while the app is active
32
+ private var watchdogTimer: Timer?
33
+
34
+ private var tickInterval: TimeInterval {
35
+ switch mode {
36
+ case "eco": return 60.0
37
+ case "aggressive": return 15.0
38
+ default: return 30.0
39
+ }
40
+ }
41
+
31
42
  // ── Lifecycle ─────────────────────────────────────────────────────────────
32
43
 
33
44
  public override func load() {
34
45
  super.load()
35
46
  loadState()
47
+ computeMissingNextDueAt()
36
48
  firePendingNativeEvents()
37
49
  let manager = BGTaskManager(plugin: self)
38
50
  manager.registerBGTasks()
@@ -45,10 +57,22 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
45
57
  name: UIApplication.didBecomeActiveNotification,
46
58
  object: nil
47
59
  )
60
+ NotificationCenter.default.addObserver(
61
+ self,
62
+ selector: #selector(appWillResignActive),
63
+ name: UIApplication.willResignActiveNotification,
64
+ object: nil
65
+ )
66
+ startWatchdog()
48
67
  }
49
68
 
50
69
  @objc private func appDidBecomeActive() {
51
70
  firePendingNativeEvents()
71
+ startWatchdog()
72
+ }
73
+
74
+ @objc private func appWillResignActive() {
75
+ stopWatchdog()
52
76
  }
53
77
 
54
78
  func handleBackgroundWake(source: String) {
@@ -57,6 +81,121 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
57
81
  notifyListeners("nativeWake", data: ["source": source, "paused": paused])
58
82
  }
59
83
 
84
+ // ── Foreground watchdog ───────────────────────────────────────────────────
85
+
86
+ private func startWatchdog() {
87
+ guard watchdogTimer == nil else { return }
88
+ watchdogTimer = Timer.scheduledTimer(withTimeInterval: tickInterval, repeats: true) { [weak self] _ in
89
+ self?.evaluateDueJobsForeground()
90
+ }
91
+ }
92
+
93
+ private func stopWatchdog() {
94
+ watchdogTimer?.invalidate()
95
+ watchdogTimer = nil
96
+ }
97
+
98
+ private func restartWatchdog() {
99
+ stopWatchdog()
100
+ startWatchdog()
101
+ }
102
+
103
+ /// Evaluate due jobs while the app is in the foreground.
104
+ /// Directly emits jobDue events via notifyListeners (no file round-trip).
105
+ private func evaluateDueJobsForeground() {
106
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
107
+ var mutated = false
108
+
109
+ for (id, var job) in jobs {
110
+ guard (job["enabled"] as? Bool) == true else { continue }
111
+ guard let schedule = job["schedule"] as? [String: Any] else { continue }
112
+
113
+ // Compute nextDueAt if missing
114
+ var nextDueAt = readLong(job["nextDueAt"])
115
+ if nextDueAt == nil {
116
+ if let computed = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
117
+ nextDueAt = computed
118
+ job["nextDueAt"] = computed
119
+ jobs[id] = job
120
+ mutated = true
121
+ }
122
+ }
123
+
124
+ guard let due = nextDueAt, due <= now else { continue }
125
+
126
+ // Check skip reasons
127
+ if paused { continue }
128
+
129
+ if let activeHours = job["activeHours"] as? [String: Any],
130
+ !NativeJobEvaluator.isWithinActiveHours(activeHours: activeHours, nowMs: now) {
131
+ job["consecutiveSkips"] = ((job["consecutiveSkips"] as? Int) ?? 0) + 1
132
+ job["updatedAt"] = now
133
+ if (schedule["kind"] as? String) == "every" {
134
+ if let next = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
135
+ job["nextDueAt"] = next
136
+ } else {
137
+ job.removeValue(forKey: "nextDueAt")
138
+ }
139
+ }
140
+ jobs[id] = job
141
+ mutated = true
142
+ continue
143
+ }
144
+
145
+ // Fire the job
146
+ job["lastFiredAt"] = now
147
+ job["updatedAt"] = now
148
+ job["consecutiveSkips"] = 0
149
+
150
+ if (schedule["kind"] as? String) == "at" {
151
+ job["enabled"] = false
152
+ job.removeValue(forKey: "nextDueAt")
153
+ } else {
154
+ if let next = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
155
+ job["nextDueAt"] = next
156
+ } else {
157
+ job.removeValue(forKey: "nextDueAt")
158
+ }
159
+ }
160
+
161
+ jobs[id] = job
162
+ mutated = true
163
+
164
+ // Emit jobDue directly to JS listeners
165
+ var payload: [String: Any] = [
166
+ "id": id,
167
+ "name": (job["name"] as? String) ?? "",
168
+ "firedAt": now,
169
+ "source": "watchdog"
170
+ ]
171
+ if let data = job["data"] {
172
+ payload["data"] = data
173
+ }
174
+ notifyListeners("jobDue", data: payload)
175
+ }
176
+
177
+ if mutated {
178
+ saveState()
179
+ }
180
+ }
181
+
182
+ /// Compute nextDueAt for all jobs that are missing it (e.g. after loadState from storage).
183
+ private func computeMissingNextDueAt() {
184
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
185
+ var mutated = false
186
+ for (id, var job) in jobs {
187
+ guard (job["enabled"] as? Bool) == true else { continue }
188
+ guard readLong(job["nextDueAt"]) == nil else { continue }
189
+ guard let schedule = job["schedule"] as? [String: Any] else { continue }
190
+ if let computed = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
191
+ job["nextDueAt"] = computed
192
+ jobs[id] = job
193
+ mutated = true
194
+ }
195
+ }
196
+ if mutated { saveState() }
197
+ }
198
+
60
199
  // ── Persistence ───────────────────────────────────────────────────────────
61
200
  // Uses NativeJobEvaluator.readStateRaw() / writeStateRaw() for file-backed
62
201
  // atomic writes that survive simctl terminate (SIGKILL) reliably.
@@ -144,19 +283,29 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
144
283
  }
145
284
 
146
285
  let id = UUID().uuidString
286
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
287
+ let schedule = call.getObject("schedule") ?? [:]
288
+
147
289
  var record: [String: Any] = [
148
290
  "id": id,
149
291
  "name": name,
150
292
  "enabled": true,
151
- "consecutiveSkips": 0
293
+ "consecutiveSkips": 0,
294
+ "createdAt": now,
295
+ "updatedAt": now
152
296
  ]
153
- if let schedule = call.getObject("schedule") { record["schedule"] = schedule }
297
+ record["schedule"] = schedule
154
298
  if let activeHours = call.getObject("activeHours") { record["activeHours"] = activeHours }
155
299
  if call.options.keys.contains("requiresNetwork") { record["requiresNetwork"] = call.getBool("requiresNetwork") ?? false }
156
300
  if call.options.keys.contains("requiresCharging") { record["requiresCharging"] = call.getBool("requiresCharging") ?? false }
157
301
  if let priority = call.getString("priority") { record["priority"] = priority }
158
302
  if let data = call.getObject("data") { record["data"] = data }
159
303
 
304
+ // Compute nextDueAt at registration time
305
+ if let nextDueAt = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
306
+ record["nextDueAt"] = nextDueAt
307
+ }
308
+
160
309
  jobs[id] = record
161
310
  saveState()
162
311
  notifyListeners("statusChanged", data: buildStatus())
@@ -185,12 +334,22 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
185
334
  }
186
335
 
187
336
  if let name = call.getString("name") { existing["name"] = name }
188
- if let schedule = call.getObject("schedule") { existing["schedule"] = schedule }
337
+ if let schedule = call.getObject("schedule") {
338
+ existing["schedule"] = schedule
339
+ // Recompute nextDueAt when schedule changes
340
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
341
+ if let next = NativeJobEvaluator.computeNextDueAt(schedule: schedule, nowMs: now) {
342
+ existing["nextDueAt"] = next
343
+ } else {
344
+ existing.removeValue(forKey: "nextDueAt")
345
+ }
346
+ }
189
347
  if call.options.keys.contains("activeHours") { existing["activeHours"] = call.getObject("activeHours") }
190
348
  if call.options.keys.contains("requiresNetwork") { existing["requiresNetwork"] = call.getBool("requiresNetwork") ?? false }
191
349
  if call.options.keys.contains("requiresCharging") { existing["requiresCharging"] = call.getBool("requiresCharging") ?? false }
192
350
  if let priority = call.getString("priority") { existing["priority"] = priority }
193
351
  if call.options.keys.contains("data") { existing["data"] = call.getObject("data") }
352
+ existing["updatedAt"] = Int64(Date().timeIntervalSince1970 * 1000)
194
353
 
195
354
  jobs[id] = existing
196
355
  saveState()
@@ -250,6 +409,7 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
250
409
  bgManager.scheduleProcessing(requiresExternalPower: mode != "aggressive")
251
410
  }
252
411
  saveState()
412
+ restartWatchdog()
253
413
  notifyListeners("statusChanged", data: buildStatus())
254
414
  call.resolve()
255
415
  }
@@ -331,4 +491,12 @@ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
331
491
  ]
332
492
  ]
333
493
  }
494
+
495
+ private func readLong(_ value: Any?) -> Int64? {
496
+ switch value {
497
+ case let n as NSNumber: return n.int64Value
498
+ case let s as String: return Int64(s)
499
+ default: return nil
500
+ }
501
+ }
334
502
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capacitor-mobilecron",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Capacitor scheduling primitive that emits job due events across web, Android, and iOS",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -20,6 +20,7 @@
20
20
  "ios",
21
21
  "dist",
22
22
  "src",
23
+ "Package.swift",
23
24
  "CapacitorMobilecron.podspec",
24
25
  "package.json",
25
26
  "README.md"