mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix(notifications): execute background actions directly via HeadlessJs without service
This commit is contained in:
@@ -77,7 +77,6 @@
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkNotificationActionService" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
|
||||
+97
-7
@@ -1,16 +1,26 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -18,17 +28,97 @@ class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank()) return
|
||||
|
||||
// Dispatch background headless task to perform JMAP operations
|
||||
val serviceIntent = Intent(context, BulwarkNotificationActionService::class.java).apply {
|
||||
intent.extras?.let { putExtras(it) }
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
context.startService(serviceIntent)
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("BulwarkNotifAction", "Failed to start action service: ${e.message}")
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
class BulwarkNotificationActionService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
||||
val extras = intent?.extras ?: return null
|
||||
return HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(extras),
|
||||
15000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,41 @@ describe('pushBackgroundTask notifications', () => {
|
||||
['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles delete action by moving email to trash', async () => {
|
||||
const postCalls: any[] = [];
|
||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
|
||||
if (opts?.body) {
|
||||
postCalls.push(JSON.parse(opts.body));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => (url.endsWith('/.well-known/jmap')
|
||||
? {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||
accounts: { 'jmap-primary': {} },
|
||||
}
|
||||
: {
|
||||
methodResponses: [
|
||||
['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
|
||||
['Email/set', { updated: { m1: {} } }, '0'],
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await handleNotificationAction({
|
||||
'bulwark.notification.action': 'delete',
|
||||
'bulwark.notification.emailId': 'm1',
|
||||
'bulwark.notification.accountId': LOCAL,
|
||||
});
|
||||
|
||||
expect(postCalls.length).toBe(2);
|
||||
expect(postCalls[1].methodCalls).toEqual([
|
||||
['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushes for device sync (#34)', () => {
|
||||
|
||||
@@ -561,16 +561,36 @@ export interface NotificationActionPayload {
|
||||
jmapAccountId?: string;
|
||||
}
|
||||
|
||||
export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined;
|
||||
const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined;
|
||||
const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined;
|
||||
const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined;
|
||||
|
||||
if ((action === 'markRead' || action === 'delete') && emailId && accountId) {
|
||||
return { action, emailId, accountId, jmapAccountId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleNotificationAction(data: unknown): Promise<void> {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const p = data as NotificationActionPayload;
|
||||
const { action, emailId, accountId } = p;
|
||||
if (!action || !emailId || !accountId) return;
|
||||
const payload = parseNotificationActionPayload(data);
|
||||
if (!payload) {
|
||||
console.warn('[push] invalid notification action payload', data);
|
||||
return;
|
||||
}
|
||||
const { action, emailId, accountId, jmapAccountId } = payload;
|
||||
console.log(`[push] executing notification action "${action}" for email ${emailId}`);
|
||||
|
||||
try {
|
||||
const session = await openDetachedSession(accountId);
|
||||
if (!session) return;
|
||||
const emailAccountId = p.jmapAccountId ?? session.jmapAccountId;
|
||||
if (!session) {
|
||||
console.warn('[push] failed to open detached session for account', accountId);
|
||||
return;
|
||||
}
|
||||
const emailAccountId = jmapAccountId ?? session.jmapAccountId;
|
||||
|
||||
if (action === 'markRead') {
|
||||
await jmapPost(session, [
|
||||
|
||||
Reference in New Issue
Block a user