capacitor-mobilecron 0.1.0

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.
@@ -0,0 +1,96 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ export interface MobileCronPlugin {
3
+ register(options: CronJobOptions): Promise<{
4
+ id: string;
5
+ }>;
6
+ unregister(options: {
7
+ id: string;
8
+ }): Promise<void>;
9
+ update(options: {
10
+ id: string;
11
+ } & Partial<CronJobOptions>): Promise<void>;
12
+ list(): Promise<{
13
+ jobs: CronJobStatus[];
14
+ }>;
15
+ triggerNow(options: {
16
+ id: string;
17
+ }): Promise<void>;
18
+ pauseAll(): Promise<void>;
19
+ resumeAll(): Promise<void>;
20
+ setMode(options: {
21
+ mode: SchedulingMode;
22
+ }): Promise<void>;
23
+ getStatus(): Promise<CronStatus>;
24
+ addListener(event: 'jobDue', handler: (data: JobDueEvent) => void): Promise<PluginListenerHandle>;
25
+ addListener(event: 'jobSkipped', handler: (data: JobSkippedEvent) => void): Promise<PluginListenerHandle>;
26
+ addListener(event: 'overdueJobs', handler: (data: OverdueEvent) => void): Promise<PluginListenerHandle>;
27
+ addListener(event: 'statusChanged', handler: (data: CronStatus) => void): Promise<PluginListenerHandle>;
28
+ }
29
+ export interface CronJobOptions {
30
+ name: string;
31
+ schedule: CronSchedule;
32
+ activeHours?: ActiveHours;
33
+ requiresNetwork?: boolean;
34
+ requiresCharging?: boolean;
35
+ priority?: 'low' | 'normal' | 'high';
36
+ data?: Record<string, unknown>;
37
+ }
38
+ export interface CronSchedule {
39
+ kind: 'every' | 'at';
40
+ everyMs?: number;
41
+ anchorMs?: number;
42
+ atMs?: number;
43
+ }
44
+ export interface ActiveHours {
45
+ start: string;
46
+ end: string;
47
+ tz?: string;
48
+ }
49
+ export type SchedulingMode = 'eco' | 'balanced' | 'aggressive';
50
+ export interface CronJobStatus {
51
+ id: string;
52
+ name: string;
53
+ enabled: boolean;
54
+ schedule: CronSchedule;
55
+ lastFiredAt?: number;
56
+ nextDueAt?: number;
57
+ consecutiveSkips: number;
58
+ data?: Record<string, unknown>;
59
+ }
60
+ export interface CronStatus {
61
+ paused: boolean;
62
+ mode: SchedulingMode;
63
+ platform: 'android' | 'ios' | 'web';
64
+ activeJobCount: number;
65
+ nextDueAt?: number;
66
+ android?: {
67
+ workManagerActive: boolean;
68
+ chargingReceiverActive: boolean;
69
+ };
70
+ ios?: {
71
+ bgRefreshRegistered: boolean;
72
+ bgProcessingRegistered: boolean;
73
+ bgContinuedAvailable: boolean;
74
+ };
75
+ }
76
+ export interface JobDueEvent {
77
+ id: string;
78
+ name: string;
79
+ firedAt: number;
80
+ source: WakeSource;
81
+ data?: Record<string, unknown>;
82
+ }
83
+ export type WakeSource = 'watchdog' | 'workmanager' | 'workmanager_chain' | 'charging' | 'foreground' | 'bgtask_refresh' | 'bgtask_processing' | 'bgtask_continued' | 'manual';
84
+ export interface JobSkippedEvent {
85
+ id: string;
86
+ name: string;
87
+ reason: 'outside_active_hours' | 'paused' | 'requires_network' | 'requires_charging';
88
+ }
89
+ export interface OverdueEvent {
90
+ count: number;
91
+ jobs: Array<{
92
+ id: string;
93
+ name: string;
94
+ overdueMs: number;
95
+ }>;
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './definitions';
2
+ export * from './mobilecron';
3
+ export { MobileCron } from './plugin';
@@ -0,0 +1,3 @@
1
+ export * from './definitions';
2
+ export * from './mobilecron';
3
+ export { MobileCron } from './plugin';
@@ -0,0 +1,74 @@
1
+ import type { ActiveHours, CronJobOptions, CronJobStatus, CronSchedule, CronStatus, JobDueEvent, JobSkippedEvent, OverdueEvent, SchedulingMode, WakeSource } from './definitions';
2
+ type PlatformName = CronStatus['platform'];
3
+ type SchedulerHooks = {
4
+ onJobDue?: (event: JobDueEvent) => void;
5
+ onJobSkipped?: (event: JobSkippedEvent) => void;
6
+ onOverdue?: (event: OverdueEvent) => void;
7
+ onStatusChanged?: (status: CronStatus) => void;
8
+ };
9
+ export type MobileCronSchedulerOptions = SchedulerHooks & {
10
+ platform?: PlatformName;
11
+ storageKey?: string;
12
+ androidDiagnostics?: CronStatus['android'];
13
+ iosDiagnostics?: CronStatus['ios'];
14
+ };
15
+ export declare class MobileCronScheduler {
16
+ private jobs;
17
+ private watchdogTimer;
18
+ private paused;
19
+ private mode;
20
+ private readonly platform;
21
+ private readonly storageKey;
22
+ private readonly hooks;
23
+ private readonly androidDiagnostics?;
24
+ private readonly iosDiagnostics?;
25
+ private initialized;
26
+ private initPromise;
27
+ private appListenerAttached;
28
+ private appIsActive;
29
+ constructor(options?: MobileCronSchedulerOptions);
30
+ init(): Promise<void>;
31
+ destroy(): Promise<void>;
32
+ register(options: CronJobOptions): Promise<{
33
+ id: string;
34
+ }>;
35
+ unregister(options: {
36
+ id: string;
37
+ }): Promise<void>;
38
+ update(options: {
39
+ id: string;
40
+ } & Partial<CronJobOptions>): Promise<void>;
41
+ list(): Promise<{
42
+ jobs: CronJobStatus[];
43
+ }>;
44
+ triggerNow(options: {
45
+ id: string;
46
+ }): Promise<void>;
47
+ pauseAll(): Promise<void>;
48
+ resumeAll(): Promise<void>;
49
+ setMode(mode: SchedulingMode): Promise<void>;
50
+ getStatus(): Promise<CronStatus>;
51
+ checkDueJobs(source: WakeSource): JobDueEvent[];
52
+ computeNextDueAt(schedule: CronSchedule, nowMs: number): number | undefined;
53
+ isWithinActiveHours(hours: ActiveHours, nowMs: number): boolean;
54
+ save(): Promise<void>;
55
+ load(): Promise<void>;
56
+ private buildStatus;
57
+ private getEarliestNextDue;
58
+ private toStatus;
59
+ private fireJob;
60
+ private getSkipReason;
61
+ private isNetworkAvailable;
62
+ private isChargingAvailable;
63
+ private validateJobOptions;
64
+ private validateSchedule;
65
+ private validateActiveHours;
66
+ private parseClock;
67
+ private normalizeSchedule;
68
+ private createId;
69
+ private startWatchdogIfNeeded;
70
+ private restartWatchdog;
71
+ private emitStatusChanged;
72
+ private attachAppStateListener;
73
+ }
74
+ export {};
@@ -0,0 +1,512 @@
1
+ import { Preferences } from '@capacitor/preferences';
2
+ const DEFAULT_STORAGE_KEY = 'mobilecron:state';
3
+ const MODE_TICKS = {
4
+ eco: 60000,
5
+ balanced: 30000,
6
+ aggressive: 15000,
7
+ };
8
+ export class MobileCronScheduler {
9
+ constructor(options = {}) {
10
+ this.jobs = new Map();
11
+ this.watchdogTimer = null;
12
+ this.paused = false;
13
+ this.mode = 'balanced';
14
+ this.initialized = false;
15
+ this.initPromise = null;
16
+ this.appListenerAttached = false;
17
+ this.appIsActive = true;
18
+ this.platform = options.platform ?? 'web';
19
+ this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
20
+ this.hooks = {
21
+ onJobDue: options.onJobDue,
22
+ onJobSkipped: options.onJobSkipped,
23
+ onOverdue: options.onOverdue,
24
+ onStatusChanged: options.onStatusChanged,
25
+ };
26
+ this.androidDiagnostics = options.androidDiagnostics;
27
+ this.iosDiagnostics = options.iosDiagnostics;
28
+ }
29
+ async init() {
30
+ if (this.initialized)
31
+ return;
32
+ if (this.initPromise)
33
+ return this.initPromise;
34
+ this.initPromise = (async () => {
35
+ await this.load();
36
+ this.startWatchdogIfNeeded();
37
+ await this.attachAppStateListener();
38
+ this.initialized = true;
39
+ this.emitStatusChanged();
40
+ })();
41
+ try {
42
+ await this.initPromise;
43
+ }
44
+ finally {
45
+ this.initPromise = null;
46
+ }
47
+ }
48
+ async destroy() {
49
+ if (this.watchdogTimer) {
50
+ clearInterval(this.watchdogTimer);
51
+ this.watchdogTimer = null;
52
+ }
53
+ }
54
+ async register(options) {
55
+ await this.init();
56
+ this.validateJobOptions(options);
57
+ const now = Date.now();
58
+ const id = this.createId();
59
+ const schedule = this.normalizeSchedule(options.schedule, now);
60
+ const job = {
61
+ id,
62
+ name: options.name.trim(),
63
+ enabled: true,
64
+ schedule,
65
+ activeHours: options.activeHours,
66
+ requiresNetwork: options.requiresNetwork ?? false,
67
+ requiresCharging: options.requiresCharging ?? false,
68
+ priority: options.priority ?? 'normal',
69
+ data: options.data,
70
+ consecutiveSkips: 0,
71
+ createdAt: now,
72
+ updatedAt: now,
73
+ nextDueAt: this.computeNextDueAt(schedule, now),
74
+ };
75
+ this.jobs.set(id, job);
76
+ await this.save();
77
+ this.emitStatusChanged();
78
+ return { id };
79
+ }
80
+ async unregister(options) {
81
+ await this.init();
82
+ this.jobs.delete(options.id);
83
+ await this.save();
84
+ this.emitStatusChanged();
85
+ }
86
+ async update(options) {
87
+ await this.init();
88
+ const existing = this.jobs.get(options.id);
89
+ if (!existing)
90
+ throw new Error(`Job not found: ${options.id}`);
91
+ const now = Date.now();
92
+ const next = { ...existing };
93
+ if (options.name !== undefined) {
94
+ if (!options.name.trim())
95
+ throw new Error('Job name is required');
96
+ next.name = options.name.trim();
97
+ }
98
+ if (options.schedule !== undefined) {
99
+ this.validateSchedule(options.schedule);
100
+ next.schedule = this.normalizeSchedule(options.schedule, now);
101
+ next.nextDueAt = this.computeNextDueAt(next.schedule, now);
102
+ }
103
+ if (options.activeHours !== undefined) {
104
+ if (options.activeHours)
105
+ this.validateActiveHours(options.activeHours);
106
+ next.activeHours = options.activeHours;
107
+ }
108
+ if (options.requiresNetwork !== undefined)
109
+ next.requiresNetwork = options.requiresNetwork;
110
+ if (options.requiresCharging !== undefined)
111
+ next.requiresCharging = options.requiresCharging;
112
+ if (options.priority !== undefined)
113
+ next.priority = options.priority;
114
+ if (options.data !== undefined)
115
+ next.data = options.data;
116
+ next.updatedAt = now;
117
+ this.jobs.set(next.id, next);
118
+ await this.save();
119
+ this.emitStatusChanged();
120
+ }
121
+ async list() {
122
+ await this.init();
123
+ const now = Date.now();
124
+ const jobs = Array.from(this.jobs.values())
125
+ .map((job) => this.toStatus(job, now))
126
+ .sort((a, b) => (a.nextDueAt ?? Number.MAX_SAFE_INTEGER) - (b.nextDueAt ?? Number.MAX_SAFE_INTEGER));
127
+ return { jobs };
128
+ }
129
+ async triggerNow(options) {
130
+ await this.init();
131
+ const job = this.jobs.get(options.id);
132
+ if (!job)
133
+ throw new Error(`Job not found: ${options.id}`);
134
+ const now = Date.now();
135
+ const event = this.fireJob(job, now, 'manual');
136
+ await this.save();
137
+ this.hooks.onJobDue?.(event);
138
+ this.emitStatusChanged();
139
+ }
140
+ async pauseAll() {
141
+ await this.init();
142
+ this.paused = true;
143
+ await this.save();
144
+ this.emitStatusChanged();
145
+ }
146
+ async resumeAll() {
147
+ await this.init();
148
+ this.paused = false;
149
+ await this.save();
150
+ this.emitStatusChanged();
151
+ }
152
+ async setMode(mode) {
153
+ await this.init();
154
+ this.mode = mode;
155
+ this.restartWatchdog();
156
+ await this.save();
157
+ this.emitStatusChanged();
158
+ }
159
+ async getStatus() {
160
+ await this.init();
161
+ return this.buildStatus(Date.now());
162
+ }
163
+ checkDueJobs(source) {
164
+ const now = Date.now();
165
+ const dueEvents = [];
166
+ const skippedEvents = [];
167
+ const overdueItems = [];
168
+ let mutated = false;
169
+ for (const job of this.jobs.values()) {
170
+ if (!job.enabled)
171
+ continue;
172
+ if (job.nextDueAt === undefined) {
173
+ job.nextDueAt = this.computeNextDueAt(job.schedule, now);
174
+ mutated = true;
175
+ }
176
+ if (job.nextDueAt === undefined || job.nextDueAt > now)
177
+ continue;
178
+ const dueAt = job.nextDueAt;
179
+ const skipReason = this.getSkipReason(job, now, source);
180
+ if (skipReason) {
181
+ job.consecutiveSkips += 1;
182
+ job.updatedAt = now;
183
+ skippedEvents.push({ id: job.id, name: job.name, reason: skipReason });
184
+ if (job.schedule.kind === 'every') {
185
+ job.nextDueAt = this.computeNextDueAt(job.schedule, now);
186
+ }
187
+ mutated = true;
188
+ continue;
189
+ }
190
+ const event = this.fireJob(job, now, source);
191
+ dueEvents.push(event);
192
+ overdueItems.push({ id: job.id, name: job.name, overdueMs: Math.max(0, now - dueAt) });
193
+ mutated = true;
194
+ }
195
+ if (mutated) {
196
+ void this.save();
197
+ this.emitStatusChanged();
198
+ }
199
+ for (const event of skippedEvents)
200
+ this.hooks.onJobSkipped?.(event);
201
+ for (const event of dueEvents)
202
+ this.hooks.onJobDue?.(event);
203
+ if (source === 'foreground' && overdueItems.length > 0) {
204
+ this.hooks.onOverdue?.({ count: overdueItems.length, jobs: overdueItems });
205
+ }
206
+ return dueEvents;
207
+ }
208
+ computeNextDueAt(schedule, nowMs) {
209
+ if (schedule.kind === 'at') {
210
+ if (typeof schedule.atMs !== 'number')
211
+ return undefined;
212
+ return schedule.atMs > nowMs ? schedule.atMs : undefined;
213
+ }
214
+ const everyMs = schedule.everyMs;
215
+ if (typeof everyMs !== 'number' || everyMs <= 0)
216
+ return undefined;
217
+ const anchorMs = typeof schedule.anchorMs === 'number' ? schedule.anchorMs : nowMs;
218
+ if (nowMs < anchorMs)
219
+ return anchorMs;
220
+ const elapsed = nowMs - anchorMs;
221
+ const steps = Math.floor(elapsed / everyMs) + 1;
222
+ return anchorMs + steps * everyMs;
223
+ }
224
+ isWithinActiveHours(hours, nowMs) {
225
+ const start = this.parseClock(hours.start);
226
+ const end = this.parseClock(hours.end);
227
+ if (start === null || end === null)
228
+ return true;
229
+ const parts = new Intl.DateTimeFormat('en-US', {
230
+ hour: '2-digit',
231
+ minute: '2-digit',
232
+ hour12: false,
233
+ timeZone: hours.tz,
234
+ }).formatToParts(new Date(nowMs));
235
+ const hh = Number(parts.find((p) => p.type === 'hour')?.value ?? '0');
236
+ const mm = Number(parts.find((p) => p.type === 'minute')?.value ?? '0');
237
+ const nowMinutes = hh * 60 + mm;
238
+ if (start === end)
239
+ return true;
240
+ if (start < end)
241
+ return nowMinutes >= start && nowMinutes < end;
242
+ return nowMinutes >= start || nowMinutes < end;
243
+ }
244
+ async save() {
245
+ const state = {
246
+ version: 1,
247
+ paused: this.paused,
248
+ mode: this.mode,
249
+ jobs: Array.from(this.jobs.values()),
250
+ };
251
+ const serialized = JSON.stringify(state);
252
+ try {
253
+ await Preferences.set({ key: this.storageKey, value: serialized });
254
+ return;
255
+ }
256
+ catch {
257
+ // Fall back to localStorage when Preferences is unavailable (web/dev)
258
+ }
259
+ if (typeof localStorage !== 'undefined') {
260
+ localStorage.setItem(this.storageKey, serialized);
261
+ }
262
+ }
263
+ async load() {
264
+ let raw = null;
265
+ try {
266
+ const result = await Preferences.get({ key: this.storageKey });
267
+ raw = result.value ?? null;
268
+ }
269
+ catch {
270
+ if (typeof localStorage !== 'undefined') {
271
+ raw = localStorage.getItem(this.storageKey);
272
+ }
273
+ }
274
+ if (!raw)
275
+ return;
276
+ let parsed = null;
277
+ try {
278
+ parsed = JSON.parse(raw);
279
+ }
280
+ catch {
281
+ return;
282
+ }
283
+ if (!parsed || parsed.version !== 1)
284
+ return;
285
+ this.paused = parsed.paused ?? false;
286
+ this.mode = parsed.mode ?? 'balanced';
287
+ this.jobs.clear();
288
+ const now = Date.now();
289
+ for (const job of parsed.jobs ?? []) {
290
+ try {
291
+ this.validateSchedule(job.schedule);
292
+ }
293
+ catch {
294
+ continue;
295
+ }
296
+ const restored = {
297
+ id: String(job.id),
298
+ name: String(job.name),
299
+ enabled: Boolean(job.enabled),
300
+ schedule: this.normalizeSchedule(job.schedule, now),
301
+ activeHours: job.activeHours,
302
+ requiresNetwork: Boolean(job.requiresNetwork),
303
+ requiresCharging: Boolean(job.requiresCharging),
304
+ priority: job.priority ?? 'normal',
305
+ data: job.data,
306
+ lastFiredAt: job.lastFiredAt,
307
+ nextDueAt: job.nextDueAt,
308
+ consecutiveSkips: Number(job.consecutiveSkips ?? 0),
309
+ createdAt: Number(job.createdAt ?? now),
310
+ updatedAt: Number(job.updatedAt ?? now),
311
+ };
312
+ if (restored.enabled && restored.nextDueAt === undefined) {
313
+ restored.nextDueAt = this.computeNextDueAt(restored.schedule, now);
314
+ }
315
+ this.jobs.set(restored.id, restored);
316
+ }
317
+ }
318
+ buildStatus(now) {
319
+ const nextDueAt = this.getEarliestNextDue(now);
320
+ return {
321
+ paused: this.paused,
322
+ mode: this.mode,
323
+ platform: this.platform,
324
+ activeJobCount: Array.from(this.jobs.values()).filter((j) => j.enabled).length,
325
+ nextDueAt,
326
+ android: this.platform === 'android'
327
+ ? (this.androidDiagnostics ?? { workManagerActive: false, chargingReceiverActive: false })
328
+ : undefined,
329
+ ios: this.platform === 'ios'
330
+ ? (this.iosDiagnostics ?? {
331
+ bgRefreshRegistered: false,
332
+ bgProcessingRegistered: false,
333
+ bgContinuedAvailable: false,
334
+ })
335
+ : undefined,
336
+ };
337
+ }
338
+ getEarliestNextDue(now) {
339
+ let earliest;
340
+ for (const job of this.jobs.values()) {
341
+ if (!job.enabled)
342
+ continue;
343
+ const next = job.nextDueAt ?? this.computeNextDueAt(job.schedule, now);
344
+ if (next === undefined)
345
+ continue;
346
+ earliest = earliest === undefined ? next : Math.min(earliest, next);
347
+ }
348
+ return earliest;
349
+ }
350
+ toStatus(job, now) {
351
+ const nextDueAt = job.enabled ? (job.nextDueAt ?? this.computeNextDueAt(job.schedule, now)) : undefined;
352
+ return {
353
+ id: job.id,
354
+ name: job.name,
355
+ enabled: job.enabled,
356
+ schedule: { ...job.schedule },
357
+ lastFiredAt: job.lastFiredAt,
358
+ nextDueAt,
359
+ consecutiveSkips: job.consecutiveSkips,
360
+ data: job.data,
361
+ };
362
+ }
363
+ fireJob(job, now, source) {
364
+ job.lastFiredAt = now;
365
+ job.updatedAt = now;
366
+ job.consecutiveSkips = 0;
367
+ if (job.schedule.kind === 'at') {
368
+ job.enabled = false;
369
+ job.nextDueAt = undefined;
370
+ }
371
+ else {
372
+ job.nextDueAt = this.computeNextDueAt(job.schedule, now);
373
+ }
374
+ return {
375
+ id: job.id,
376
+ name: job.name,
377
+ firedAt: now,
378
+ source,
379
+ data: job.data,
380
+ };
381
+ }
382
+ getSkipReason(job, now, source) {
383
+ if (this.paused)
384
+ return 'paused';
385
+ if (job.activeHours && !this.isWithinActiveHours(job.activeHours, now))
386
+ return 'outside_active_hours';
387
+ if (job.requiresNetwork && !this.isNetworkAvailable())
388
+ return 'requires_network';
389
+ if (job.requiresCharging && !this.isChargingAvailable(source))
390
+ return 'requires_charging';
391
+ return null;
392
+ }
393
+ isNetworkAvailable() {
394
+ if (typeof navigator === 'undefined')
395
+ return true;
396
+ return navigator.onLine !== false;
397
+ }
398
+ isChargingAvailable(source) {
399
+ if (source === 'charging')
400
+ return true;
401
+ // Web fallback has no portable charging API. Native wake sources should enforce this.
402
+ return false;
403
+ }
404
+ validateJobOptions(options) {
405
+ if (!options.name?.trim())
406
+ throw new Error('Job name is required');
407
+ this.validateSchedule(options.schedule);
408
+ if (options.activeHours)
409
+ this.validateActiveHours(options.activeHours);
410
+ }
411
+ validateSchedule(schedule) {
412
+ if (schedule.kind === 'every') {
413
+ if (typeof schedule.everyMs !== 'number' || !Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) {
414
+ throw new Error('schedule.everyMs must be a positive number');
415
+ }
416
+ if (this.platform !== 'web' && schedule.everyMs < 60000) {
417
+ throw new Error('schedule.everyMs must be at least 60000 on mobile');
418
+ }
419
+ return;
420
+ }
421
+ if (schedule.kind === 'at') {
422
+ if (typeof schedule.atMs !== 'number' || !Number.isFinite(schedule.atMs)) {
423
+ throw new Error('schedule.atMs must be a valid epoch milliseconds timestamp');
424
+ }
425
+ return;
426
+ }
427
+ throw new Error(`Unsupported schedule kind: ${schedule.kind ?? 'unknown'}`);
428
+ }
429
+ validateActiveHours(hours) {
430
+ if (this.parseClock(hours.start) === null)
431
+ throw new Error('activeHours.start must be HH:MM');
432
+ if (this.parseClock(hours.end) === null)
433
+ throw new Error('activeHours.end must be HH:MM');
434
+ if (hours.tz) {
435
+ try {
436
+ new Intl.DateTimeFormat('en-US', { timeZone: hours.tz }).format(new Date());
437
+ }
438
+ catch {
439
+ throw new Error(`Invalid time zone: ${hours.tz}`);
440
+ }
441
+ }
442
+ }
443
+ parseClock(value) {
444
+ const m = /^(\d{2}):(\d{2})$/.exec(value);
445
+ if (!m)
446
+ return null;
447
+ const hh = Number(m[1]);
448
+ const mm = Number(m[2]);
449
+ if (hh < 0 || hh > 23 || mm < 0 || mm > 59)
450
+ return null;
451
+ return hh * 60 + mm;
452
+ }
453
+ normalizeSchedule(schedule, now) {
454
+ if (schedule.kind === 'every') {
455
+ return {
456
+ kind: 'every',
457
+ everyMs: schedule.everyMs,
458
+ anchorMs: schedule.anchorMs ?? now,
459
+ };
460
+ }
461
+ return {
462
+ kind: 'at',
463
+ atMs: schedule.atMs,
464
+ };
465
+ }
466
+ createId() {
467
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
468
+ return crypto.randomUUID();
469
+ }
470
+ return `job_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
471
+ }
472
+ startWatchdogIfNeeded() {
473
+ if (this.watchdogTimer || !this.appIsActive)
474
+ return;
475
+ const tickMs = MODE_TICKS[this.mode];
476
+ this.watchdogTimer = setInterval(() => {
477
+ this.checkDueJobs('watchdog');
478
+ }, tickMs);
479
+ }
480
+ restartWatchdog() {
481
+ if (this.watchdogTimer) {
482
+ clearInterval(this.watchdogTimer);
483
+ this.watchdogTimer = null;
484
+ }
485
+ this.startWatchdogIfNeeded();
486
+ }
487
+ emitStatusChanged() {
488
+ this.hooks.onStatusChanged?.(this.buildStatus(Date.now()));
489
+ }
490
+ async attachAppStateListener() {
491
+ if (this.appListenerAttached)
492
+ return;
493
+ try {
494
+ const mod = await import('@capacitor/app');
495
+ await mod.App.addListener('appStateChange', ({ isActive }) => {
496
+ this.appIsActive = isActive;
497
+ if (isActive) {
498
+ this.startWatchdogIfNeeded();
499
+ this.checkDueJobs('foreground');
500
+ }
501
+ else if (this.watchdogTimer) {
502
+ clearInterval(this.watchdogTimer);
503
+ this.watchdogTimer = null;
504
+ }
505
+ });
506
+ this.appListenerAttached = true;
507
+ }
508
+ catch {
509
+ // App plugin is optional in some environments.
510
+ }
511
+ }
512
+ }
@@ -0,0 +1,2 @@
1
+ import type { MobileCronPlugin } from './definitions';
2
+ export declare const MobileCron: MobileCronPlugin;
@@ -0,0 +1,4 @@
1
+ import { registerPlugin } from '@capacitor/core';
2
+ export const MobileCron = registerPlugin('MobileCron', {
3
+ web: () => import('./web').then((m) => new m.MobileCronWeb()),
4
+ });