diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 98a7ca5b71..7a555f992e 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -1263,6 +1263,12 @@ public void screenshot(SuccessCallback callback) { callback.onSucess(img); } + /** + * Notifies the platform that push notification processing is complete. + */ + public void notifyPushCompletion() { + } + /** * Returns true if the platform supports a native image cache. The native image cache * is different than just {@link FileSystemStorage#hasCachesDir()}. A native image cache diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 82c02fecd5..6d675b87cf 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -5154,6 +5154,22 @@ public void screenshot(SuccessCallback callback) { impl.screenshot(callback); } + /** + * Notifies the platform that push notification processing is complete. + * This is useful on iOS where the app is woken up in the background to handle + * a push notification and needs to signal completion to avoid being suspended + * prematurely. + *

+ * If the {@code ios.delayPushCompletion} build hint (or property) is set to "true", + * Codename One will NOT automatically signal completion after the {@link com.codename1.push.PushCallback#push(String)} + * method returns. Instead, the application MUST invoke this method manually + * when it has finished its background work (e.g. playing audio, downloading content). + *

+ */ + public void notifyPushCompletion() { + impl.notifyPushCompletion(); + } + /** * Convenience method to schedule a task to run on the EDT after {@literal timeout}ms. * diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 45acab0480..e65c9800a0 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -52,7 +52,8 @@ import android.graphics.drawable.Drawable; import android.media.AudioManager; import android.net.Uri; -import android.os.Vibrator; +import android.os.Vibrator; +import android.os.PowerManager; import android.telephony.TelephonyManager; import android.util.DisplayMetrics; import android.util.Log; @@ -280,6 +281,19 @@ public static void setActivity(CodenameOneActivity aActivity) { private int displayHeight; static CodenameOneActivity activity; static ComponentName activityComponentName; + private static PowerManager.WakeLock pushWakeLock; + public static void acquirePushWakeLock(long timeout) { + if (getContext() == null) return; + try { + if (pushWakeLock == null) { + PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); + pushWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CN1:PushWakeLock"); + } + pushWakeLock.acquire(timeout); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } private static Context context; RelativeLayout relativeLayout; @@ -2783,7 +2797,18 @@ public void exitApplication() { android.os.Process.killProcess(android.os.Process.myPid()); } - @Override + @Override + public void notifyPushCompletion() { + if (pushWakeLock != null && pushWakeLock.isHeld()) { + try { + pushWakeLock.release(); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + } + + @Override public void notifyCommandBehavior(int commandBehavior) { if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { if (getActivity() instanceof CodenameOneActivity) { diff --git a/Ports/Android/src/com/codename1/impl/android/PushNotificationService.java b/Ports/Android/src/com/codename1/impl/android/PushNotificationService.java index e9995b047e..5708bec7a8 100644 --- a/Ports/Android/src/com/codename1/impl/android/PushNotificationService.java +++ b/Ports/Android/src/com/codename1/impl/android/PushNotificationService.java @@ -109,9 +109,20 @@ public void onDestroy() { public void push(final String value) { final PushCallback callback = getPushCallbackInstance(); if(callback != null) { + final boolean delayPushCompletion = "true".equals(Display.getInstance().getProperty("delayPushCompletion", "false")) || + "true".equals(Display.getInstance().getProperty("android.delayPushCompletion", "false")); + if (delayPushCompletion) { + AndroidImplementation.acquirePushWakeLock(30000); + } Display.getInstance().callSerially(new Runnable() { public void run() { - callback.push(value); + try { + callback.push(value); + } finally { + if (!delayPushCompletion) { + Display.getInstance().notifyPushCompletion(); + } + } } }); } else { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 5b9bf946b1..23c555b794 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -8115,7 +8115,10 @@ public void run() { pushCallback.push(message); } finally { - nativeInstance.firePushCompletionHandler(); + if (!"true".equals(Display.getInstance().getProperty("delayPushCompletion", "false")) && + !"true".equals(Display.getInstance().getProperty("ios.delayPushCompletion", "false"))) { + nativeInstance.firePushCompletionHandler(); + } } } }); diff --git a/docs/developer-guide/Push-Notifications.asciidoc b/docs/developer-guide/Push-Notifications.asciidoc index f32d45108f..5aa7b4738a 100644 --- a/docs/developer-guide/Push-Notifications.asciidoc +++ b/docs/developer-guide/Push-Notifications.asciidoc @@ -87,6 +87,44 @@ NOTE: On iOS, hidden push messages (push type 2) will not be delivered when the TIP: You can set the `android.background_push_handling` build hint to "true" to deliver push messages on Android when the app is minimized (running in the background). There is no equivalent setting on other platforms currently. +[[delay-push-completion]] +=== Handling Long-Running Background Push Tasks + +By default, the platform signals the completion of the push processing immediately after your `push(String)` callback returns. However, some tasks, such as playing audio (e.g. for Push-To-Talk apps), require more time to complete. If the app is in the background, the OS might suspend the app before the task completes if it thinks the push handling is finished. + +To handle this, you can opt-in to manually signaling the completion of the push task using the `delayPushCompletion` build hint. + +1. Add the `delayPushCompletion=true` build hint to your `codenameone_settings.properties`. +2. In your `push(String)` callback, start your asynchronous task. +3. When your task is complete, call `Display.getInstance().notifyPushCompletion()`. + +Example: + +[source,java] +---- +public void push(String message) { + if (isAudioMessage(message)) { + // Play audio asynchronously + playAudio(message, new Runnable() { + public void run() { + // Audio finished playing + Display.getInstance().notifyPushCompletion(); + } + }); + } else { + // For standard messages, we can notify immediately or let it timeout (safest to notify) + Display.getInstance().notifyPushCompletion(); + } +} +---- + +**How it works:** + +* **Android:** The system acquires a `PARTIAL_WAKE_LOCK` when the push is received, keeping the CPU running even if the screen is off. Calling `notifyPushCompletion()` releases this lock. The lock has a safety timeout (e.g., 30 seconds) to prevent battery drain if you forget to call it. +* **iOS:** The system delays calling the completion handler passed to the push delegate. This gives your app background execution time. Calling `notifyPushCompletion()` invokes the system completion handler. + +NOTE: If you enable this feature, you **must** call `Display.getInstance().notifyPushCompletion()` in all code paths of your `push()` callback to ensure the device can sleep/suspend properly. + === Testing Push Support diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 2c54664a20..559ad9ebde 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -1163,6 +1163,10 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc playFlag = "true"; gpsPermission = request.getArg("android.gpsPermission", "false").equals("true"); + if (request.getArg("android.delayPushCompletion", "false").equals("true") || + request.getArg("delayPushCompletion", "false").equals("true")) { + wakeLock = true; + } mediaPlaybackPermission = false; try { scanClassesForPermissions(dummyClassesDir, new Executor.ClassScanner() { @@ -3894,6 +3898,10 @@ private String createOnDestroyCode(BuildRequest request) { private String createPostInitCode(BuildRequest request) { String retVal = ""; + if (request.getArg("android.delayPushCompletion", "false").equals("true") || + request.getArg("delayPushCompletion", "false").equals("true")) { + retVal += "Display.getInstance().setProperty(\"android.delayPushCompletion\", \"true\");\n"; + } return retVal; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4cda55ef23..10794d10f7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -956,8 +956,14 @@ public void usesClassMethod(String cls, String method) { + " private boolean stopped = false;\n"; stubSourceCode += decodeFunction(); + String delayPushCompletion = ""; + if ("true".equals(request.getArg("ios.delayPushCompletion", "false")) || + "true".equals(request.getArg("delayPushCompletion", "false"))) { + delayPushCompletion = " Display.getInstance().setProperty(\"ios.delayPushCompletion\", \"true\");\n"; + } stubSourceCode += " public void run() {\n" + " Display.getInstance().setProperty(\"package_name\", PACKAGE_NAME);\n" + + delayPushCompletion + " Display.getInstance().setProperty(\"AppVersion\", APPLICATION_VERSION);\n" + " Display.getInstance().setProperty(\"AppName\", APPLICATION_NAME);\n" + newStorage @@ -2380,7 +2386,8 @@ public boolean accept(File file, String string) { } } String backgroundModesStr = request.getArg("ios.background_modes", null); - if (includePush) { + if (includePush || "true".equals(request.getArg("ios.delayPushCompletion", "false")) || + "true".equals(request.getArg("delayPushCompletion", "false"))) { if (backgroundModesStr == null || !backgroundModesStr.contains("remote-notification")) { if (backgroundModesStr == null) { backgroundModesStr = "";