Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -488,17 +488,35 @@ internal object PushBackground {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
val jobs = context.getSystemService(JobScheduler::class.java) ?: return false
// The last hand-off never started: the system is holding jobs back, so run in the receiver.
if (jobs.getPendingJob(EXPEDITED_JOB_ID) != null) {
jobs.cancel(EXPEDITED_JOB_ID)
Comment on lines +493 to +494

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Don't mistake a running job for a held-back job

getPendingJob() also returns jobs that have already started, and cancel() immediately stops a running job. Because ticks can run longer than the 15-second alarm interval (the configured wakelock allows up to 60 seconds), a slow but healthy expedited run is treated as held back; this cancels its JobService execution window and queues another tick through the receiver.

Prompt To Fix With AI
This is a comment left during a code review.
Path: templates/android/library/src/main/java/io/package/services/PushBackground.kt.twig
Line: 493-494

Comment:
**[minor] Don't mistake a running job for a held-back job**

`getPendingJob()` also returns jobs that have already started, and `cancel()` immediately stops a running job. Because ticks can run longer than the 15-second alarm interval (the configured wakelock allows up to 60 seconds), a slow but healthy expedited run is treated as held back; this cancels its JobService execution window and queues another tick through the receiver.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · bug · Reply if this doesn't apply.

return false
}
return try {
val job = JobInfo.Builder(EXPEDITED_JOB_ID, ComponentName(context, PushJobService::class.java))
.setExpedited(true)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.build()
context.getSystemService(JobScheduler::class.java)?.schedule(job) == JobScheduler.RESULT_SUCCESS
jobs.schedule(job) == JobScheduler.RESULT_SUCCESS
} catch (e: Exception) {
false
}
}

/** Arm the next alarm of the chain without touching the jobs (the alarm handed its run to one). */
fun armNextAlarm(context: Context) {
val app = context.applicationContext
val active = synchronized(lock) {
load(app)
isActive()
}
if (active) {
schedule(app, INTERVAL_MS, alarmOnly = true)
}
}

/** Run once more shortly after the task was removed, so an OEM kill that follows is undone. */
fun scheduleRestart(context: Context) {
schedule(context.applicationContext, RESTART_DELAY_MS, alarmOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ class PushAlarmReceiver : BroadcastReceiver() {
return
}
// On Android 12+ the run goes to an expedited job, which keeps the process runnable while it
// connects and the broker replays; it runs here when the system refuses one.
// connects and the broker replays; it runs here when the system refuses one. The next alarm
// is armed first: an accepted job is not a started one (some phones hold jobs back for
// thermal or vendor limits), and the chain must not depend on it.
if (PushBackground.runExpedited(context)) {
PushBackground.armNextAlarm(context)
return
}
val pending = goAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import android.app.Activity
import android.app.AlarmManager
import android.app.Application
import android.app.Notification
import android.app.job.JobScheduler
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
Expand All @@ -23,6 +24,7 @@ import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.network.ForwardingCookieHandler
import io.appwrite.reactnative.AppwriteCookiesModule
import io.appwrite.reactnative.AppwritePushModule
import io.appwrite.reactnative.services.PushAlarmReceiver
import io.appwrite.reactnative.services.PushBackground
import io.appwrite.reactnative.services.PushBridge
import io.appwrite.reactnative.services.PushMessage
Expand Down Expand Up @@ -728,3 +730,89 @@ class PushBackgroundUnblockTest {
const val SESSION = "eyJpZCI6ImUyZS1zZXNzaW9uLXVzZXIiLCJzZWNyZXQiOiJlMmUtc2VjcmV0In0="
}
}

/**
* Android 12+ hands each alarm's run to an expedited job. Some phones accept the job and never
* start it (thermal limits, vendor power managers), and only a run arms the next alarm, so the
* alarm arms it itself, and runs the tick in the receiver while its last hand-off is still pending.
*/
@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.S])
@RunWith(AndroidJUnit4::class)
class PushAlarmChainTest {
private val context = ApplicationProvider.getApplicationContext<Application>()
private val module = AppwritePushModule(BridgeReactContext(context)) { _, _ -> }

@Test
fun testAlarmChainSurvivesHeldBackJobs() {
val config = JSONObject()
.put("host", System.getenv("PUSH_HOST") ?: "mqtt")
.put("port", 1883)
.put("tls", false)
.put("authMethod", "appwrite-session")
.put("credential", SESSION)
.put("project", "console")
.put("clientId", "e2e-alarm-chain")
.toString()
val subscriptions = JSONArray()
.put(JSONObject().put("id", "chain").put("topic", "e2e-alarm-chain").put("background", true).put("retry", true))
.toString()
call { module.host(config, subscriptions, it) }

val alarmManager = context.getSystemService(AlarmManager::class.java)
val alarms = shadowOf(alarmManager)
val jobs = context.getSystemService(JobScheduler::class.java)
val receiver = PushAlarmReceiver()
context.registerReceiver(receiver, IntentFilter(PushBackground.ACTION_TICK))
fun expeditedPending() = jobs.allPendingJobs.any { it.id == PushBackground.EXPEDITED_JOB_ID }
// Android fires the pending alarm, which consumes it.
fun fire() {
alarms.nextScheduledAlarm?.operation?.let { alarmManager.cancel(it) }
context.sendBroadcast(Intent(PushBackground.ACTION_TICK))
shadowOf(Looper.getMainLooper()).idle()
}
try {
jobs.cancel(PushBackground.EXPEDITED_JOB_ID)
fire()
assertTrue("the alarm hands its run to an expedited job", expeditedPending())
assertTrue("the alarm arms the next one itself", alarms.nextScheduledAlarm != null)

// Robolectric does not run jobs: the hand-off is still pending, as on a phone that holds
// it back. The next alarm runs the tick in the receiver, which re-arms the chain (its
// job here; Robolectric does not record alarms set off the main thread).
jobs.cancel(PushBackground.JOB_ID)
jobs.cancel(PushBackground.NEXT_JOB_ID)
fire()
waitFor { jobs.allPendingJobs.any { it.id == PushBackground.JOB_ID || it.id == PushBackground.NEXT_JOB_ID } }
assertFalse("no second hand-off while the first never started", expeditedPending())
assertTrue(
"the run in the receiver re-arms the chain",
jobs.allPendingJobs.any { it.id == PushBackground.JOB_ID || it.id == PushBackground.NEXT_JOB_ID },
)
} finally {
context.unregisterReceiver(receiver)
call { module.stop(it) }
}
}

private fun call(block: (Promise) -> Unit): Result<Any?> {
val promise = TestPromise()
block(promise)
while (!promise.settled.isDone) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(50)
}
return promise.await()
}

private fun waitFor(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition() && System.currentTimeMillis() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
Thread.sleep(100)
}
}

private companion object {
const val SESSION = "eyJpZCI6ImUyZS1zZXNzaW9uLXVzZXIiLCJzZWNyZXQiOiJlMmUtc2VjcmV0In0="
}
}
Loading