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.
- package/README.md +281 -0
- package/android/build.gradle +45 -0
- package/android/proguard-rules.pro +2 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/io/mobilecron/ChargingReceiver.kt +13 -0
- package/android/src/main/java/io/mobilecron/CronBridge.kt +20 -0
- package/android/src/main/java/io/mobilecron/CronChainWorker.kt +36 -0
- package/android/src/main/java/io/mobilecron/CronWorker.kt +15 -0
- package/android/src/main/java/io/mobilecron/MobileCronPlugin.kt +234 -0
- package/dist/.gitkeep +0 -0
- package/dist/esm/definitions.d.ts +96 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/mobilecron.d.ts +74 -0
- package/dist/esm/mobilecron.js +512 -0
- package/dist/esm/plugin.d.ts +2 -0
- package/dist/esm/plugin.js +4 -0
- package/dist/esm/web.d.ts +32 -0
- package/dist/esm/web.js +54 -0
- package/ios/Plugin/BGTaskManager.swift +78 -0
- package/ios/Plugin/MobileCronPlugin.m +13 -0
- package/ios/Plugin/MobileCronPlugin.swift +169 -0
- package/package.json +84 -0
- package/src/definitions.ts +99 -0
- package/src/index.ts +3 -0
- package/src/mobilecron.ts +598 -0
- package/src/plugin.ts +6 -0
- package/src/web.ts +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# capacitor-mobilecron
|
|
2
|
+
|
|
3
|
+
> Lightweight Capacitor scheduling primitive — register jobs, get `jobDue` events when they fire, across web, Android, and iOS.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/capacitor-mobilecron)
|
|
6
|
+
[](https://github.com/rogelioRuiz/capacitor-mobilecron/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
`capacitor-mobilecron` manages a set of named, persistent cron-like jobs and emits events when they are due. It handles:
|
|
12
|
+
|
|
13
|
+
- **Repeating intervals** (`every: N ms`) with optional anchor alignment
|
|
14
|
+
- **One-shot schedules** (`at: epoch ms`) that auto-disable after firing
|
|
15
|
+
- **Active-hour windows** — restrict jobs to HH:MM–HH:MM ranges with timezone support
|
|
16
|
+
- **Network / charging constraints** — skip a job when connectivity or power is absent
|
|
17
|
+
- **Scheduling modes** — `eco` (60s watchdog), `balanced` (30s), `aggressive` (15s)
|
|
18
|
+
- **App foreground wakeup** — immediately checks overdue jobs when the app comes to the foreground
|
|
19
|
+
- **Persistent state** — job registry survives app restarts via `@capacitor/preferences` (falls back to `localStorage` on web)
|
|
20
|
+
|
|
21
|
+
The web implementation is fully functional and self-contained. Android and iOS stubs satisfy the Capacitor plugin contract and can be extended with native WorkManager / BGTaskScheduler wakeups.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install capacitor-mobilecron
|
|
27
|
+
npx cap sync
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Peer dependencies** you need in your Capacitor app:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @capacitor/core @capacitor/preferences
|
|
34
|
+
# @capacitor/app is optional — enables foreground-wakeup check
|
|
35
|
+
npm install @capacitor/app
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { MobileCron } from 'capacitor-mobilecron'
|
|
42
|
+
|
|
43
|
+
// Listen for due events
|
|
44
|
+
await MobileCron.addListener('jobDue', ({ id, name, firedAt, source, data }) => {
|
|
45
|
+
console.log(`Job "${name}" fired at ${new Date(firedAt).toISOString()} via ${source}`)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Register a repeating job — every 5 minutes
|
|
49
|
+
const { id } = await MobileCron.register({
|
|
50
|
+
name: 'sync-data',
|
|
51
|
+
schedule: { kind: 'every', everyMs: 5 * 60 * 1000 },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Register a one-shot job — fires at a specific time
|
|
55
|
+
await MobileCron.register({
|
|
56
|
+
name: 'daily-reminder',
|
|
57
|
+
schedule: { kind: 'at', atMs: Date.now() + 24 * 60 * 60 * 1000 },
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
### `register(options)`
|
|
64
|
+
|
|
65
|
+
Register a new job. Returns the job `id`.
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const { id } = await MobileCron.register({
|
|
69
|
+
name: 'my-job',
|
|
70
|
+
schedule: { kind: 'every', everyMs: 60_000 }, // every 60 s
|
|
71
|
+
activeHours: { start: '08:00', end: '22:00', tz: 'America/Chicago' },
|
|
72
|
+
requiresNetwork: true,
|
|
73
|
+
requiresCharging: false,
|
|
74
|
+
priority: 'normal',
|
|
75
|
+
data: { userId: '42' }, // passed back in jobDue event
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Option | Type | Description |
|
|
80
|
+
|--------|------|-------------|
|
|
81
|
+
| `name` | `string` | Human-readable label |
|
|
82
|
+
| `schedule` | `CronSchedule` | When to fire (see below) |
|
|
83
|
+
| `activeHours` | `ActiveHours?` | Restrict firing to a time window |
|
|
84
|
+
| `requiresNetwork` | `boolean?` | Skip when offline |
|
|
85
|
+
| `requiresCharging` | `boolean?` | Skip when not charging |
|
|
86
|
+
| `priority` | `'low' \| 'normal' \| 'high'?` | Scheduling hint |
|
|
87
|
+
| `data` | `Record<string, unknown>?` | Arbitrary payload returned in events |
|
|
88
|
+
|
|
89
|
+
### Schedules
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// Repeat every N ms (minimum 60 000 ms on native)
|
|
93
|
+
{ kind: 'every', everyMs: 300_000 }
|
|
94
|
+
|
|
95
|
+
// Repeat every N ms, aligned to an anchor timestamp
|
|
96
|
+
{ kind: 'every', everyMs: 3600_000, anchorMs: Date.now() }
|
|
97
|
+
|
|
98
|
+
// Fire once at an absolute epoch timestamp
|
|
99
|
+
{ kind: 'at', atMs: Date.parse('2025-01-01T09:00:00Z') }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `unregister({ id })`
|
|
103
|
+
|
|
104
|
+
Remove a job and stop it from firing.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
await MobileCron.unregister({ id })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `update({ id, ...partial })`
|
|
111
|
+
|
|
112
|
+
Patch an existing job without losing its state.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
await MobileCron.update({
|
|
116
|
+
id,
|
|
117
|
+
schedule: { kind: 'every', everyMs: 10 * 60 * 1000 },
|
|
118
|
+
activeHours: { start: '09:00', end: '17:00' },
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `list()`
|
|
123
|
+
|
|
124
|
+
Returns all registered jobs sorted by next due time.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
const { jobs } = await MobileCron.list()
|
|
128
|
+
for (const job of jobs) {
|
|
129
|
+
console.log(job.name, 'next due at', new Date(job.nextDueAt ?? 0).toISOString())
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `triggerNow({ id })`
|
|
134
|
+
|
|
135
|
+
Force a job to fire immediately (source = `'manual'`).
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
await MobileCron.triggerNow({ id })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `pauseAll()` / `resumeAll()`
|
|
142
|
+
|
|
143
|
+
Suspend / resume all job checks globally.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
await MobileCron.pauseAll()
|
|
147
|
+
await MobileCron.resumeAll()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### `setMode({ mode })`
|
|
151
|
+
|
|
152
|
+
Control how frequently the watchdog timer checks for due jobs.
|
|
153
|
+
|
|
154
|
+
| Mode | Interval | Use case |
|
|
155
|
+
|------|----------|---------|
|
|
156
|
+
| `'eco'` | 60 s | Battery-sensitive background |
|
|
157
|
+
| `'balanced'` | 30 s | Default |
|
|
158
|
+
| `'aggressive'` | 15 s | Real-time UX needs |
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
await MobileCron.setMode({ mode: 'aggressive' })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `getStatus()`
|
|
165
|
+
|
|
166
|
+
Returns scheduler diagnostics.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
const status = await MobileCron.getStatus()
|
|
170
|
+
// {
|
|
171
|
+
// paused: false,
|
|
172
|
+
// mode: 'balanced',
|
|
173
|
+
// platform: 'android',
|
|
174
|
+
// activeJobCount: 3,
|
|
175
|
+
// nextDueAt: 1719000000000,
|
|
176
|
+
// android: { workManagerActive: true, chargingReceiverActive: false }
|
|
177
|
+
// }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Events
|
|
181
|
+
|
|
182
|
+
| Event | Payload | Description |
|
|
183
|
+
|-------|---------|-------------|
|
|
184
|
+
| `jobDue` | `JobDueEvent` | A job fired |
|
|
185
|
+
| `jobSkipped` | `JobSkippedEvent` | A due job was skipped (constraint not met) |
|
|
186
|
+
| `overdueJobs` | `OverdueEvent` | Emitted on foreground resume if jobs are overdue |
|
|
187
|
+
| `statusChanged` | `CronStatus` | Scheduler state changed |
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
MobileCron.addListener('jobSkipped', ({ id, name, reason }) => {
|
|
191
|
+
// reason: 'outside_active_hours' | 'paused' | 'requires_network' | 'requires_charging'
|
|
192
|
+
console.warn(`Job ${name} skipped: ${reason}`)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
MobileCron.addListener('overdueJobs', ({ count, jobs }) => {
|
|
196
|
+
console.warn(`${count} jobs were overdue on resume`)
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Advanced: `MobileCronScheduler`
|
|
201
|
+
|
|
202
|
+
The package also exports the plain TypeScript scheduler class that powers the web plugin. Use it directly in Node.js, React Native, or any non-Capacitor environment:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { MobileCronScheduler } from 'capacitor-mobilecron'
|
|
206
|
+
|
|
207
|
+
const scheduler = new MobileCronScheduler({
|
|
208
|
+
platform: 'web',
|
|
209
|
+
onJobDue: (event) => handleJobDue(event),
|
|
210
|
+
onJobSkipped: (event) => console.warn('skipped', event),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await scheduler.init()
|
|
214
|
+
|
|
215
|
+
const { id } = await scheduler.register({
|
|
216
|
+
name: 'heartbeat',
|
|
217
|
+
schedule: { kind: 'every', everyMs: 30_000 },
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Later — check due jobs from a native wakeup callback
|
|
221
|
+
scheduler.checkDueJobs('workmanager')
|
|
222
|
+
|
|
223
|
+
// Teardown
|
|
224
|
+
await scheduler.destroy()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## iOS / Android native wakeups
|
|
228
|
+
|
|
229
|
+
The web/JS watchdog is the primary scheduling mechanism. For true background execution extend the native stubs:
|
|
230
|
+
|
|
231
|
+
### Android
|
|
232
|
+
|
|
233
|
+
Wire `CronWorker` into `WorkManager` periodic tasks and call `bridge.checkDueJobs("workmanager")`. Register `ChargingReceiver` in the manifest for charging wakeups.
|
|
234
|
+
|
|
235
|
+
### iOS
|
|
236
|
+
|
|
237
|
+
Register a `BGAppRefreshTask` / `BGProcessingTask` in your `AppDelegate` and call the plugin method to check due jobs from there.
|
|
238
|
+
|
|
239
|
+
## Testing
|
|
240
|
+
|
|
241
|
+
### Unit tests — no device needed
|
|
242
|
+
|
|
243
|
+
The scheduler core is pure TypeScript and fully testable without a phone:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
npm test # run 52 unit tests (instant)
|
|
247
|
+
npm run test:watch # TDD watch mode
|
|
248
|
+
npm run test:coverage # coverage report
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Tests cover schedule computation, active-hour windows, persistence, pause/resume, skip logic, and more.
|
|
252
|
+
|
|
253
|
+
### Device E2E tests (Android, via CDP)
|
|
254
|
+
|
|
255
|
+
Full integration suite against a running Android app — 7 sections, 40+ tests:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
# 1. Forward CDP port from device
|
|
259
|
+
adb forward tcp:9222 localabstract:webview_devtools_remote_$(adb shell pidof io.mobileclaw.reference)
|
|
260
|
+
|
|
261
|
+
# 2. Run
|
|
262
|
+
npm run test:e2e
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
See [`tests/e2e/test-e2e.mjs`](tests/e2e/test-e2e.mjs) for coverage details.
|
|
266
|
+
|
|
267
|
+
## Contributing
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
git clone https://github.com/rogelioRuiz/capacitor-mobilecron.git
|
|
271
|
+
cd capacitor-mobilecron
|
|
272
|
+
npm install
|
|
273
|
+
npm test # unit tests — runs in seconds, no device
|
|
274
|
+
npm run build # compile TypeScript
|
|
275
|
+
npm run lint # Biome linter
|
|
276
|
+
npm run typecheck # TypeScript strict mode
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
MIT © Rogelio Ruiz
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
ext {
|
|
2
|
+
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
|
3
|
+
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
|
|
4
|
+
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
apply plugin: 'com.android.library'
|
|
8
|
+
apply plugin: 'kotlin-android'
|
|
9
|
+
|
|
10
|
+
android {
|
|
11
|
+
namespace "io.mobilecron"
|
|
12
|
+
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
|
13
|
+
|
|
14
|
+
defaultConfig {
|
|
15
|
+
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
|
16
|
+
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
|
17
|
+
versionCode 1
|
|
18
|
+
versionName "0.1.0"
|
|
19
|
+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
20
|
+
consumerProguardFiles 'proguard-rules.pro'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
compileOptions {
|
|
24
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
25
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
kotlinOptions {
|
|
29
|
+
jvmTarget = '17'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
repositories {
|
|
34
|
+
google()
|
|
35
|
+
mavenCentral()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dependencies {
|
|
39
|
+
implementation project(':capacitor-android')
|
|
40
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib"
|
|
41
|
+
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
|
42
|
+
testImplementation "junit:junit:$junitVersion"
|
|
43
|
+
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
44
|
+
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package io.mobilecron
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
|
|
7
|
+
class ChargingReceiver : BroadcastReceiver() {
|
|
8
|
+
override fun onReceive(context: Context?, intent: Intent?) {
|
|
9
|
+
if (intent?.action == Intent.ACTION_POWER_CONNECTED) {
|
|
10
|
+
CronBridge.wake("charging")
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package io.mobilecron
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
|
|
6
|
+
object CronBridge {
|
|
7
|
+
@Volatile
|
|
8
|
+
var plugin: MobileCronPlugin? = null
|
|
9
|
+
|
|
10
|
+
fun wake(source: String) {
|
|
11
|
+
val current = plugin ?: return
|
|
12
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
13
|
+
current.notifyFromBackground(source)
|
|
14
|
+
} else {
|
|
15
|
+
Handler(Looper.getMainLooper()).post {
|
|
16
|
+
plugin?.notifyFromBackground(source)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package io.mobilecron
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.work.CoroutineWorker
|
|
5
|
+
import androidx.work.ExistingWorkPolicy
|
|
6
|
+
import androidx.work.OneTimeWorkRequestBuilder
|
|
7
|
+
import androidx.work.WorkManager
|
|
8
|
+
import androidx.work.WorkerParameters
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
|
|
11
|
+
class CronChainWorker(
|
|
12
|
+
appContext: Context,
|
|
13
|
+
params: WorkerParameters
|
|
14
|
+
) : CoroutineWorker(appContext, params) {
|
|
15
|
+
override suspend fun doWork(): Result {
|
|
16
|
+
CronBridge.wake("workmanager_chain")
|
|
17
|
+
enqueueNext(applicationContext, 5)
|
|
18
|
+
return Result.success()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
companion object {
|
|
22
|
+
private const val UNIQUE_NAME = "mobilecron_chain"
|
|
23
|
+
|
|
24
|
+
fun enqueueNext(context: Context, delayMinutes: Long) {
|
|
25
|
+
val request = OneTimeWorkRequestBuilder<CronChainWorker>()
|
|
26
|
+
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
|
|
27
|
+
.build()
|
|
28
|
+
|
|
29
|
+
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
30
|
+
UNIQUE_NAME,
|
|
31
|
+
ExistingWorkPolicy.REPLACE,
|
|
32
|
+
request
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package io.mobilecron
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.work.CoroutineWorker
|
|
5
|
+
import androidx.work.WorkerParameters
|
|
6
|
+
|
|
7
|
+
class CronWorker(
|
|
8
|
+
appContext: Context,
|
|
9
|
+
params: WorkerParameters
|
|
10
|
+
) : CoroutineWorker(appContext, params) {
|
|
11
|
+
override suspend fun doWork(): Result {
|
|
12
|
+
CronBridge.wake("workmanager")
|
|
13
|
+
return Result.success()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
package io.mobilecron
|
|
2
|
+
|
|
3
|
+
import android.content.Intent
|
|
4
|
+
import android.content.IntentFilter
|
|
5
|
+
import androidx.work.Constraints
|
|
6
|
+
import androidx.work.ExistingPeriodicWorkPolicy
|
|
7
|
+
import androidx.work.ExistingWorkPolicy
|
|
8
|
+
import androidx.work.NetworkType
|
|
9
|
+
import androidx.work.OneTimeWorkRequestBuilder
|
|
10
|
+
import androidx.work.PeriodicWorkRequestBuilder
|
|
11
|
+
import androidx.work.WorkManager
|
|
12
|
+
import com.getcapacitor.JSArray
|
|
13
|
+
import com.getcapacitor.JSObject
|
|
14
|
+
import com.getcapacitor.Plugin
|
|
15
|
+
import com.getcapacitor.PluginCall
|
|
16
|
+
import com.getcapacitor.PluginMethod
|
|
17
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
18
|
+
import java.util.UUID
|
|
19
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
20
|
+
import java.util.concurrent.TimeUnit
|
|
21
|
+
|
|
22
|
+
@CapacitorPlugin(name = "MobileCron")
|
|
23
|
+
class MobileCronPlugin : Plugin() {
|
|
24
|
+
private val jobs = ConcurrentHashMap<String, JSObject>()
|
|
25
|
+
private var paused = false
|
|
26
|
+
private var mode = "balanced"
|
|
27
|
+
private var chargingReceiver: ChargingReceiver? = null
|
|
28
|
+
|
|
29
|
+
override fun load() {
|
|
30
|
+
super.load()
|
|
31
|
+
CronBridge.plugin = this
|
|
32
|
+
registerChargingReceiver()
|
|
33
|
+
scheduleWorkManager()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun handleOnDestroy() {
|
|
37
|
+
super.handleOnDestroy()
|
|
38
|
+
if (CronBridge.plugin === this) {
|
|
39
|
+
CronBridge.plugin = null
|
|
40
|
+
}
|
|
41
|
+
chargingReceiver?.let {
|
|
42
|
+
runCatching { context.unregisterReceiver(it) }
|
|
43
|
+
}
|
|
44
|
+
chargingReceiver = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
internal fun notifyFromBackground(source: String) {
|
|
48
|
+
// Native phase-1 skeleton: background wakes are surfaced, but due-job evaluation is implemented in TS web layer.
|
|
49
|
+
val payload = JSObject()
|
|
50
|
+
payload.put("source", source)
|
|
51
|
+
payload.put("paused", paused)
|
|
52
|
+
notifyListeners("statusChanged", buildStatus())
|
|
53
|
+
notifyListeners("nativeWake", payload)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private fun scheduleWorkManager() {
|
|
57
|
+
val wm = WorkManager.getInstance(context)
|
|
58
|
+
if (mode == "aggressive") {
|
|
59
|
+
val request = OneTimeWorkRequestBuilder<CronChainWorker>()
|
|
60
|
+
.setInitialDelay(5, TimeUnit.MINUTES)
|
|
61
|
+
.build()
|
|
62
|
+
wm.enqueueUniqueWork("mobilecron_chain", ExistingWorkPolicy.REPLACE, request)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val constraintsBuilder = Constraints.Builder()
|
|
67
|
+
if (mode == "eco") {
|
|
68
|
+
constraintsBuilder.setRequiredNetworkType(NetworkType.UNMETERED)
|
|
69
|
+
constraintsBuilder.setRequiresBatteryNotLow(true)
|
|
70
|
+
} else {
|
|
71
|
+
constraintsBuilder.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
val request = PeriodicWorkRequestBuilder<CronWorker>(15, TimeUnit.MINUTES)
|
|
75
|
+
.setConstraints(constraintsBuilder.build())
|
|
76
|
+
.build()
|
|
77
|
+
|
|
78
|
+
wm.enqueueUniquePeriodicWork(
|
|
79
|
+
"mobilecron_periodic",
|
|
80
|
+
ExistingPeriodicWorkPolicy.UPDATE,
|
|
81
|
+
request
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private fun registerChargingReceiver() {
|
|
86
|
+
if (chargingReceiver != null) return
|
|
87
|
+
val receiver = ChargingReceiver()
|
|
88
|
+
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_POWER_CONNECTED))
|
|
89
|
+
chargingReceiver = receiver
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@PluginMethod
|
|
93
|
+
fun register(call: PluginCall) {
|
|
94
|
+
val name = call.getString("name")?.trim()
|
|
95
|
+
if (name.isNullOrEmpty()) {
|
|
96
|
+
call.reject("Job name is required")
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
val id = UUID.randomUUID().toString()
|
|
101
|
+
val record = JSObject()
|
|
102
|
+
record.put("id", id)
|
|
103
|
+
record.put("name", name)
|
|
104
|
+
record.put("enabled", true)
|
|
105
|
+
record.put("schedule", call.getObject("schedule") ?: JSObject())
|
|
106
|
+
record.put("activeHours", call.getObject("activeHours"))
|
|
107
|
+
record.put("requiresNetwork", call.getBoolean("requiresNetwork", false))
|
|
108
|
+
record.put("requiresCharging", call.getBoolean("requiresCharging", false))
|
|
109
|
+
record.put("priority", call.getString("priority", "normal"))
|
|
110
|
+
call.getObject("data")?.let { record.put("data", it) }
|
|
111
|
+
record.put("consecutiveSkips", 0)
|
|
112
|
+
jobs[id] = record
|
|
113
|
+
|
|
114
|
+
val result = JSObject()
|
|
115
|
+
result.put("id", id)
|
|
116
|
+
call.resolve(result)
|
|
117
|
+
notifyListeners("statusChanged", buildStatus())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@PluginMethod
|
|
121
|
+
fun unregister(call: PluginCall) {
|
|
122
|
+
val id = call.getString("id")
|
|
123
|
+
if (id == null) {
|
|
124
|
+
call.reject("id is required")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
jobs.remove(id)
|
|
128
|
+
call.resolve()
|
|
129
|
+
notifyListeners("statusChanged", buildStatus())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@PluginMethod
|
|
133
|
+
fun update(call: PluginCall) {
|
|
134
|
+
val id = call.getString("id")
|
|
135
|
+
if (id == null) {
|
|
136
|
+
call.reject("id is required")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
val existing = jobs[id]
|
|
140
|
+
if (existing == null) {
|
|
141
|
+
call.reject("Job not found")
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
call.getString("name")?.let { existing.put("name", it) }
|
|
146
|
+
call.getObject("schedule")?.let { existing.put("schedule", it) }
|
|
147
|
+
if (call.data.has("activeHours")) existing.put("activeHours", call.getObject("activeHours"))
|
|
148
|
+
if (call.data.has("requiresNetwork")) existing.put("requiresNetwork", call.getBoolean("requiresNetwork", false))
|
|
149
|
+
if (call.data.has("requiresCharging")) existing.put("requiresCharging", call.getBoolean("requiresCharging", false))
|
|
150
|
+
call.getString("priority")?.let { existing.put("priority", it) }
|
|
151
|
+
if (call.data.has("data")) existing.put("data", call.getObject("data"))
|
|
152
|
+
|
|
153
|
+
jobs[id] = existing
|
|
154
|
+
call.resolve()
|
|
155
|
+
notifyListeners("statusChanged", buildStatus())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@PluginMethod
|
|
159
|
+
fun list(call: PluginCall) {
|
|
160
|
+
val arr = JSArray()
|
|
161
|
+
jobs.values.forEach { arr.put(it) }
|
|
162
|
+
val result = JSObject()
|
|
163
|
+
result.put("jobs", arr)
|
|
164
|
+
call.resolve(result)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@PluginMethod
|
|
168
|
+
fun triggerNow(call: PluginCall) {
|
|
169
|
+
val id = call.getString("id")
|
|
170
|
+
if (id == null) {
|
|
171
|
+
call.reject("id is required")
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
val job = jobs[id]
|
|
175
|
+
if (job == null) {
|
|
176
|
+
call.reject("Job not found")
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
val payload = JSObject()
|
|
181
|
+
payload.put("id", id)
|
|
182
|
+
payload.put("name", job.getString("name"))
|
|
183
|
+
payload.put("firedAt", System.currentTimeMillis())
|
|
184
|
+
payload.put("source", "manual")
|
|
185
|
+
if (job.has("data")) payload.put("data", job.getJSONObject("data"))
|
|
186
|
+
notifyListeners("jobDue", payload)
|
|
187
|
+
call.resolve()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@PluginMethod
|
|
191
|
+
fun pauseAll(call: PluginCall) {
|
|
192
|
+
paused = true
|
|
193
|
+
call.resolve()
|
|
194
|
+
notifyListeners("statusChanged", buildStatus())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@PluginMethod
|
|
198
|
+
fun resumeAll(call: PluginCall) {
|
|
199
|
+
paused = false
|
|
200
|
+
call.resolve()
|
|
201
|
+
notifyListeners("statusChanged", buildStatus())
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@PluginMethod
|
|
205
|
+
fun setMode(call: PluginCall) {
|
|
206
|
+
val next = call.getString("mode")
|
|
207
|
+
if (next !in listOf("eco", "balanced", "aggressive")) {
|
|
208
|
+
call.reject("mode must be eco|balanced|aggressive")
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
mode = next!!
|
|
212
|
+
scheduleWorkManager()
|
|
213
|
+
call.resolve()
|
|
214
|
+
notifyListeners("statusChanged", buildStatus())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@PluginMethod
|
|
218
|
+
fun getStatus(call: PluginCall) {
|
|
219
|
+
call.resolve(buildStatus())
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun buildStatus(): JSObject {
|
|
223
|
+
val status = JSObject()
|
|
224
|
+
status.put("paused", paused)
|
|
225
|
+
status.put("mode", mode)
|
|
226
|
+
status.put("platform", "android")
|
|
227
|
+
status.put("activeJobCount", jobs.size)
|
|
228
|
+
status.put("android", JSObject().apply {
|
|
229
|
+
put("workManagerActive", true)
|
|
230
|
+
put("chargingReceiverActive", chargingReceiver != null)
|
|
231
|
+
})
|
|
232
|
+
return status
|
|
233
|
+
}
|
|
234
|
+
}
|
package/dist/.gitkeep
ADDED
|
File without changes
|