diff --git a/.github/workflows/check-android-gms-isolation.yml b/.github/workflows/check-android-gms-isolation.yml
new file mode 100644
index 00000000000..461f35e5b75
--- /dev/null
+++ b/.github/workflows/check-android-gms-isolation.yml
@@ -0,0 +1,47 @@
+name: Check Android Play services isolation
+
+# Codename One applications run on Android without Google Play services
+# (GrapheneOS, CalyxOS, /e/OS, LineageOS without GApps, Huawei). There the Play
+# services libraries are still in the APK but the service behind them is not, so
+# every call into them has to sit behind GoogleServices.isPlayServicesAvailable.
+# This holds the Android port to that: only the guarded proxies listed in
+# scripts/android-gms-isolation-allowlist.txt may name a Google Play type.
+#
+# Pure source scan, seconds, no build. Its own workflow so the paths filter can
+# be exactly the inputs it reads.
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - master
+ paths:
+ - 'Ports/Android/src/**'
+ - 'scripts/check-android-gms-isolation.py'
+ - 'scripts/android-gms-isolation-allowlist.txt'
+ - '.github/workflows/check-android-gms-isolation.yml'
+ push:
+ branches:
+ - master
+ paths:
+ - 'Ports/Android/src/**'
+ - 'scripts/check-android-gms-isolation.py'
+ - 'scripts/android-gms-isolation-allowlist.txt'
+ - '.github/workflows/check-android-gms-isolation.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check-android-gms-isolation:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Google Play types only in guarded proxies
+ shell: bash
+ run: scripts/check-android-gms-isolation.py
diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml
index 3726c551738..7b6986efa41 100644
--- a/.github/workflows/scripts-android.yml
+++ b/.github/workflows/scripts-android.yml
@@ -161,9 +161,19 @@ jobs:
- name: "JDK 17"
id: jdk17
java_version: '17'
+ # The JDK 21 leg also runs the suite on an AOSP image WITHOUT Google
+ # apps, the device GrapheneOS, CalyxOS, /e/OS and LineageOS users have:
+ # every test that touches Play services has to fall back or skip there
+ # instead of hanging or failing (GoogleServices.isPlayServicesAvailable).
+ # The AOSP image defaults to 3-button navigation, a grey bar over the
+ # bottom 48px of every screenshot that the Google image does not draw;
+ # emulator_setup switches it to gesture navigation so the stored
+ # references apply unchanged.
- name: "JDK 21"
id: jdk21
java_version: '21'
+ emulator_target: default
+ emulator_setup: adb shell cmd overlay enable com.android.internal.systemui.navbar.gestural && sleep 5
permissions:
contents: read
pull-requests: write
@@ -320,9 +330,11 @@ jobs:
with:
api-level: 36
arch: x86_64
- target: google_apis
+ target: ${{ matrix.emulator_target || 'google_apis' }}
disk-size: 2048M
- script: ./scripts/run-android-instrumentation-tests.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}"
+ script: |
+ ${{ matrix.emulator_setup || 'true' }}
+ ./scripts/run-android-instrumentation-tests.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}"
# After the emulator, deliberately: the instrumentation suite runs against
# the APK the primary build produced, and this rewrites that project in
# place. It is the end-to-end half of the API 37 coverage -- the offline
diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml
index 8f3c7ea094c..d81fed57d2a 100644
--- a/Ports/Android/build.xml
+++ b/Ports/Android/build.xml
@@ -130,7 +130,7 @@
a package to one file is not adding it. -->
+ excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/unifiedpush/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java">
diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties
index 263aa92f2d3..acabc30cc96 100644
--- a/Ports/Android/nbproject/project.properties
+++ b/Ports/Android/nbproject/project.properties
@@ -36,7 +36,7 @@ endorsed.classpath=
# excludes= attribute in build.xml. The list lives in three files; the Maven
# build is green with only one of them updated, so adding a package here alone
# is not adding it.
-excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java
+excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/webrtc/**,com/codename1/impl/android/unifiedpush/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java
file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar
file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar
file.reference.android-support-v7-gridlayout.jar=../../../cn1-binaries/android/android-support-v7-gridlayout.jar
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index fa81cfe3ef6..94bd8620cbd 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -4109,8 +4109,7 @@ public String getProperty(String key, String defaultValue) {
/*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){
return "";
}*/
- boolean has = hasAndroidMarket();
- if(has) {
+ if(GoogleServices.isPlayServicesAvailable(getContext())) {
return "gcm";
}
return defaultValue;
@@ -9450,7 +9449,11 @@ public LocationManager getLocationManager() {
}
boolean includesPlayServices = Display.getInstance().getProperty("IncludeGPlayServices", "false").equals("true");
- if (includesPlayServices && hasAndroidMarket()) {
+ // Play services, not the Play Store: the fused provider is a service of
+ // the former, and a device can carry either without the other. Without
+ // it the framework manager covers the same API, background updates and
+ // geofences included, instead of a GoogleApiClient that never connects.
+ if (includesPlayServices && GoogleServices.isPlayServicesAvailable(getContext())) {
try {
Class clazz = Class.forName("com.codename1.location.AndroidLocationPlayServiceManager");
return (com.codename1.location.LocationManager)clazz.getMethod("getInstance").invoke(null);
@@ -14070,27 +14073,17 @@ public void systemOut(String content){
Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), content);
}
- private boolean hasAndroidMarket() {
- return hasAndroidMarket(getContext());
- }
-
- private static final String GooglePlayStorePackageNameOld = "com.google.market";
- private static final String GooglePlayStorePackageNameNew = "com.android.vending";
/**
* Indicates whether this is a Google certified device which means that it
* has Android market etc.
*/
public static boolean hasAndroidMarket(Context activity) {
- final PackageManager packageManager = activity.getPackageManager();
- List packages = packageManager.getInstalledPackages(PackageManager.GET_UNINSTALLED_PACKAGES);
- for (PackageInfo packageInfo : packages) {
- if (packageInfo.packageName.equals(GooglePlayStorePackageNameOld) ||
- packageInfo.packageName.equals(GooglePlayStorePackageNameNew)) {
- return true;
- }
- }
- return false;
+ // A direct lookup of the two packages rather than a scan of every
+ // installed one: from API 30 getInstalledPackages() lists only what the
+ // manifest's names, and a scan answers false on a Google device
+ // as readily as on a de-Googled one. The builder declares these packages.
+ return GoogleServices.isPlayStoreInstalled(activity);
}
@Override
@@ -14106,8 +14099,21 @@ public void registerPush(Hashtable metaData, boolean noFallback) {
}
boolean huawei = "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
- if (!hasAndroidMarket() && !huawei) {
- Log.d("Codename One", "Device doesn't have Android market/google play can't register for push!");
+ if (!hasAlternativePushTransport() && !GoogleServices.isPlayServicesAvailable(getContext())) {
+ // Reported, not just logged: a PushClient waits for exactly one
+ // registration result, and returning in silence left it waiting for
+ // good on every device without Google Play services.
+ final String message = "Google Play services is not available on this device, and "
+ + "the application was built with no other push transport";
+ Log.d("Codename One", message);
+ final com.codename1.push.PushCallback callback = getPushCallback();
+ if (callback != null) {
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ callback.pushRegistrationError(message, 0);
+ }
+ });
+ }
return;
}
String id = "";
@@ -14129,10 +14135,23 @@ public static void registerPolling() {
registerPollingFallback();
}
+ /// Whether the build compiled in a push transport other than Firebase
+ /// (Huawei Push Kit, UnifiedPush). The generated activity then chooses among
+ /// them at run time in registerForPush, so the Play services check above
+ /// must not decide for it.
+ private static boolean hasAlternativePushTransport() {
+ Display d = Display.getInstance();
+ if ("huawei".equals(d.getProperty("cn1.push.transport", ""))) {
+ return true;
+ }
+ String transports = d.getProperty("cn1.push.transports", "");
+ return transports.indexOf("huawei") > -1 || transports.indexOf("unifiedpush") > -1;
+ }
+
@Override
public void deregisterPush() {
- boolean has = hasAndroidMarket()
- || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", ""));
+ boolean has = hasAlternativePushTransport()
+ || GoogleServices.isPlayServicesAvailable(getContext());
if (has) {
((CodenameOneActivity) getActivity()).stopReceivingPush();
deregisterPushFromServer();
@@ -17825,16 +17844,7 @@ public void unsubscribeFromPushTopic(final String topic) {
}
private void invokeFirebaseTopic(String methodName, String topic) {
- try {
- Class> cls = Class.forName("com.google.firebase.messaging.FirebaseMessaging");
- Object instance = cls.getMethod("getInstance").invoke(null);
- cls.getMethod(methodName, String.class).invoke(instance, topic);
- } catch (ClassNotFoundException notAvailable) {
- com.codename1.io.Log.p("Firebase Cloud Messaging is not available; topic '" + topic
- + "' subscription must be handled server side");
- } catch (Throwable t) {
- com.codename1.io.Log.e(t);
- }
+ GoogleServices.firebaseTopic(getContext(), methodName, topic);
}
@Override
@@ -18304,74 +18314,13 @@ public boolean isJailbrokenDevice() {
@Override
public boolean isAttestationSupported() {
- try {
- Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory");
- return true;
- } catch(Throwable t) {
- return false;
- }
+ return GoogleServices.isPlayIntegritySupported(getContext());
}
@Override
public AsyncResource requestIntegrityToken(final String nonce) {
final AsyncResource result = new AsyncResource();
- try {
- Context context = getContext();
- Class factory = Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory");
- Object manager = factory.getMethod("create", Context.class).invoke(null, context);
- Class requestClass = Class.forName("com.google.android.play.core.integrity.IntegrityTokenRequest");
- Object builder = requestClass.getMethod("builder").invoke(null);
- builder = builder.getClass().getMethod("setNonce", String.class).invoke(builder, nonce);
- Object request = builder.getClass().getMethod("build").invoke(builder);
- Class managerClass = Class.forName("com.google.android.play.core.integrity.IntegrityManager");
- Object task = managerClass.getMethod("requestIntegrityToken", requestClass).invoke(manager, request);
-
- Class taskClass = Class.forName("com.google.android.gms.tasks.Task");
- Class onSuccessClass = Class.forName("com.google.android.gms.tasks.OnSuccessListener");
- Class onFailureClass = Class.forName("com.google.android.gms.tasks.OnFailureListener");
- final Class responseClass = Class.forName("com.google.android.play.core.integrity.IntegrityTokenResponse");
-
- Object successListener = java.lang.reflect.Proxy.newProxyInstance(
- onSuccessClass.getClassLoader(), new Class[] { onSuccessClass },
- new java.lang.reflect.InvocationHandler() {
- public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
- try {
- Object response = args[0];
- Object token = responseClass.getMethod("token").invoke(response);
- // Tested rather than cast into the catch below: a
- // wrong type here is a bad token rather than a
- // failed call, and a reflective call's answer is
- // exactly the kind of value worth testing.
- if (token instanceof String) {
- result.complete((String) token);
- } else {
- result.error(new IllegalStateException(
- "integrity token was not a string"));
- }
- } catch(Throwable t) {
- result.error(t);
- }
- return null;
- }
- });
- Object failureListener = java.lang.reflect.Proxy.newProxyInstance(
- onFailureClass.getClassLoader(), new Class[] { onFailureClass },
- new java.lang.reflect.InvocationHandler() {
- public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
- Throwable err = (args != null && args.length > 0 && args[0] instanceof Throwable)
- ? (Throwable) args[0] : new RuntimeException("Play Integrity request failed");
- result.error(err);
- return null;
- }
- });
- taskClass.getMethod("addOnSuccessListener", onSuccessClass).invoke(task, successListener);
- taskClass.getMethod("addOnFailureListener", onFailureClass).invoke(task, failureListener);
- } catch(ClassNotFoundException notBundled) {
- result.error(new UnsupportedOperationException(
- "Google Play Integrity is not bundled. Enable the android.playIntegrity build hint."));
- } catch(Throwable t) {
- result.error(t);
- }
+ GoogleServices.requestIntegrityToken(getContext(), nonce, result);
return result;
}
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java
index e1fbfad9c9a..154c258e85f 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidWearableSupport.java
@@ -61,6 +61,12 @@ private AndroidWearableSupport() {
///
/// the wearable bridge, or null
public static synchronized WearableBridge getBridge(android.content.Context context) {
+ if (!GoogleServices.isPlayServicesAvailable(context)) {
+ // The Data Layer is a Play services API; without the service every call
+ // fails, so the API stays inert. Not remembered, so a user who installs
+ // Play services later (GrapheneOS sandboxed Play) gets the bridge.
+ return null;
+ }
if (!lookedUp) {
lookedUp = true;
try {
diff --git a/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java b/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java
index a312bb38c0f..611100cc89f 100644
--- a/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java
+++ b/Ports/Android/src/com/codename1/impl/android/AppReviewSupport.java
@@ -58,10 +58,13 @@ private AppReviewSupport() {
static boolean isSupported() {
try {
Class.forName(FACTORY);
- return true;
} catch (Throwable t) {
return false;
}
+ // The review flow is served by the Play Store app, which a de-Googled
+ // device does not have; reporting support there only set up a request
+ // that was certain to fail.
+ return GoogleServices.isPlayStoreInstalled(AndroidNativeUtil.getContext());
}
/**
diff --git a/Ports/Android/src/com/codename1/impl/android/GoogleServices.java b/Ports/Android/src/com/codename1/impl/android/GoogleServices.java
new file mode 100644
index 00000000000..253c26f2a96
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/GoogleServices.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.impl.android;
+
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import com.codename1.util.AsyncResource;
+
+/// The one place outside an optional feature package that talks to Google Play
+/// services, the Play Store or Firebase.
+///
+/// Codename One applications run unmodified on Android builds that ship without
+/// Google's apps -- GrapheneOS, CalyxOS, /e/OS, LineageOS without GApps, Amazon
+/// and Huawei devices -- and on GrapheneOS where Play services is installed as an
+/// ordinary sandboxed app that the user may remove at any time. On those devices
+/// the Play services client libraries are still inside the APK, so nothing fails
+/// to load; what fails is the service behind them. A `GoogleApiClient` never
+/// connects, a `Task` completes with `API_UNAVAILABLE`, and a connection
+/// resolution shows the "Get Google Play services" prompt. Every entry point
+/// therefore asks [#isPlayServicesAvailable(Context)] first and falls back or
+/// reports itself unsupported.
+///
+/// Nothing here names a Google type at compile time. Everything is reached by
+/// name, so the class compiles into the port jar and loads on a device where the
+/// libraries were never bundled. `scripts/check-android-gms-isolation.py` holds
+/// the rest of the port to that: a class that names a Google Play type must be
+/// listed there, and must be one that guards its calls this way.
+///
+/// Package visibility: from API 30 `PackageManager` only answers for packages the
+/// manifest declares in ``. The builder declares both packages below for
+/// every application, so these lookups see them.
+public final class GoogleServices {
+ /// The package of Google Play services.
+ public static final String PLAY_SERVICES_PACKAGE = "com.google.android.gms";
+
+ /// The package of the Google Play Store.
+ public static final String PLAY_STORE_PACKAGE = "com.android.vending";
+
+ private static final String LEGACY_MARKET_PACKAGE = "com.google.market";
+
+ private static final String API_AVAILABILITY =
+ "com.google.android.gms.common.GoogleApiAvailability";
+
+ private static final String INTEGRITY_FACTORY =
+ "com.google.android.play.core.integrity.IntegrityManagerFactory";
+
+ private GoogleServices() {
+ }
+
+ /// Whether Google Play services is installed, enabled and usable here.
+ ///
+ /// The package must be present and enabled -- a user can disable it, and
+ /// GrapheneOS lets them uninstall it. When the application bundles the Play
+ /// services base library its own verdict is asked as well, so a version too
+ /// missing, disabled or invalid service answers false rather than handing the
+ /// caller a client that will never connect. An outdated or updating service
+ /// still answers true: Play services recovers from both by itself.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ ///
+ /// #### Returns
+ ///
+ /// true only when calls into Play services can succeed
+ public static boolean isPlayServicesAvailable(Context context) {
+ if (context == null || !isPackageEnabled(context, PLAY_SERVICES_PACKAGE)) {
+ return false;
+ }
+ try {
+ Class> availability = Class.forName(API_AVAILABILITY);
+ Object instance = availability.getMethod("getInstance").invoke(null);
+ Object result = availability.getMethod("isGooglePlayServicesAvailable", Context.class)
+ .invoke(instance, context);
+ if (!(result instanceof Integer)) {
+ return false;
+ }
+ // ConnectionResult codes. SUCCESS (0) is plainly present. So are
+ // SERVICE_VERSION_UPDATE_REQUIRED (2) and SERVICE_UPDATING (18): that is
+ // a Google device whose Play services is outdated or mid-update, where
+ // the client recovers or prompts the update, exactly as before this
+ // check existed. Answering false there would lock such a device into the
+ // fallbacks for the whole process. SERVICE_MISSING, SERVICE_DISABLED and
+ // SERVICE_INVALID are the de-Googled cases.
+ int code = ((Integer) result).intValue();
+ return code == 0 || code == 2 || code == 18;
+ } catch (ClassNotFoundException notBundled) {
+ // The application never bundled the base library, so nothing in it will
+ // call the service through a client; the installed package is the answer.
+ return true;
+ } catch (Throwable err) {
+ return false;
+ }
+ }
+
+ /// Whether the Google Play Store is installed and enabled.
+ ///
+ /// Play Core APIs -- in-app review, Play Integrity -- are served by the store,
+ /// not by Play services, so they ask this instead.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ ///
+ /// #### Returns
+ ///
+ /// true when the Play Store app is present and enabled
+ public static boolean isPlayStoreInstalled(Context context) {
+ return context != null && (isPackageEnabled(context, PLAY_STORE_PACKAGE)
+ || isPackageEnabled(context, LEGACY_MARKET_PACKAGE));
+ }
+
+ /// Whether `packageName` is installed and enabled.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ /// - `packageName`: the package to look up
+ ///
+ /// #### Returns
+ ///
+ /// true when it is installed and not disabled
+ public static boolean isPackageEnabled(Context context, String packageName) {
+ try {
+ ApplicationInfo info = context.getPackageManager().getApplicationInfo(packageName, 0);
+ return info != null && info.enabled;
+ } catch (PackageManager.NameNotFoundException absent) {
+ return false;
+ } catch (Throwable err) {
+ return false;
+ }
+ }
+
+ /// Subscribes to or unsubscribes from a Firebase Cloud Messaging topic.
+ ///
+ /// Topics are an FCM feature. Without Play services, or with another push
+ /// transport registered, there is nothing to subscribe on the device and the
+ /// application's server has to do the fan-out; this says so in the log.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ /// - `methodName`: `subscribeToTopic` or `unsubscribeFromTopic`
+ /// - `topic`: the topic name
+ static void firebaseTopic(Context context, String methodName, String topic) {
+ if (!isPlayServicesAvailable(context)) {
+ com.codename1.io.Log.p("Google Play services is not available; topic '" + topic
+ + "' subscription must be handled server side");
+ return;
+ }
+ try {
+ Class> cls = Class.forName("com.google.firebase.messaging.FirebaseMessaging");
+ Object instance = cls.getMethod("getInstance").invoke(null);
+ cls.getMethod(methodName, String.class).invoke(instance, topic);
+ } catch (ClassNotFoundException notAvailable) {
+ com.codename1.io.Log.p("Firebase Cloud Messaging is not available; topic '" + topic
+ + "' subscription must be handled server side");
+ } catch (Throwable t) {
+ com.codename1.io.Log.e(t);
+ }
+ }
+
+ /// Whether the Play Integrity library is bundled and the Play Store that
+ /// serves it is installed.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ ///
+ /// #### Returns
+ ///
+ /// true when a token request can be attempted
+ static boolean isPlayIntegritySupported(Context context) {
+ try {
+ Class.forName(INTEGRITY_FACTORY);
+ } catch (Throwable t) {
+ return false;
+ }
+ return isPlayStoreInstalled(context);
+ }
+
+ /// Requests a Play Integrity token for `nonce`, completing `result`.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ /// - `nonce`: the request nonce
+ /// - `result`: completed with the token or an error
+ static void requestIntegrityToken(Context context, final String nonce,
+ final AsyncResource result) {
+ if (context != null && !isPlayStoreInstalled(context)) {
+ // Answered here with a reason a developer can act on, rather than as
+ // Play Core's numeric error code from a service that is not there.
+ result.error(new UnsupportedOperationException(
+ "Google Play Integrity needs the Google Play Store, which is not installed"));
+ return;
+ }
+ try {
+ Class factory = Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory");
+ Object manager = factory.getMethod("create", Context.class).invoke(null, context);
+ Class requestClass = Class.forName("com.google.android.play.core.integrity.IntegrityTokenRequest");
+ Object builder = requestClass.getMethod("builder").invoke(null);
+ builder = builder.getClass().getMethod("setNonce", String.class).invoke(builder, nonce);
+ Object request = builder.getClass().getMethod("build").invoke(builder);
+ Class managerClass = Class.forName("com.google.android.play.core.integrity.IntegrityManager");
+ Object task = managerClass.getMethod("requestIntegrityToken", requestClass).invoke(manager, request);
+
+ Class taskClass = Class.forName("com.google.android.gms.tasks.Task");
+ Class onSuccessClass = Class.forName("com.google.android.gms.tasks.OnSuccessListener");
+ Class onFailureClass = Class.forName("com.google.android.gms.tasks.OnFailureListener");
+ final Class responseClass = Class.forName("com.google.android.play.core.integrity.IntegrityTokenResponse");
+
+ Object successListener = java.lang.reflect.Proxy.newProxyInstance(
+ onSuccessClass.getClassLoader(), new Class[] { onSuccessClass },
+ new java.lang.reflect.InvocationHandler() {
+ public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
+ try {
+ Object response = args[0];
+ Object token = responseClass.getMethod("token").invoke(response);
+ // Tested rather than cast into the catch below: a
+ // wrong type here is a bad token rather than a
+ // failed call, and a reflective call's answer is
+ // exactly the kind of value worth testing.
+ if (token instanceof String) {
+ result.complete((String) token);
+ } else {
+ result.error(new IllegalStateException(
+ "integrity token was not a string"));
+ }
+ } catch(Throwable t) {
+ result.error(t);
+ }
+ return null;
+ }
+ });
+ Object failureListener = java.lang.reflect.Proxy.newProxyInstance(
+ onFailureClass.getClassLoader(), new Class[] { onFailureClass },
+ new java.lang.reflect.InvocationHandler() {
+ public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
+ Throwable err = (args != null && args.length > 0 && args[0] instanceof Throwable)
+ ? (Throwable) args[0] : new RuntimeException("Play Integrity request failed");
+ result.error(err);
+ return null;
+ }
+ });
+ taskClass.getMethod("addOnSuccessListener", onSuccessClass).invoke(task, successListener);
+ taskClass.getMethod("addOnFailureListener", onFailureClass).invoke(task, failureListener);
+ } catch(ClassNotFoundException notBundled) {
+ result.error(new UnsupportedOperationException(
+ "Google Play Integrity is not bundled. Enable the android.playIntegrity build hint."));
+ } catch(Throwable t) {
+ result.error(t);
+ }
+ }
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java
index 74c8539cb86..1efe8b78dfb 100644
--- a/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java
+++ b/Ports/Android/src/com/codename1/impl/android/nearby/AndroidNearbyTransport.java
@@ -324,10 +324,16 @@ private ConnectionsClient client() {
// ------------------------------------------------------------------
public boolean isTransportSupported() {
- return true;
+ // Nearby Connections is a service of Google Play services. Without it --
+ // GrapheneOS, LineageOS without GApps, Huawei -- every call fails through
+ // its failure listener, so the transport is reported as absent instead.
+ return com.codename1.impl.android.GoogleServices.isPlayServicesAvailable(context);
}
public int getTransportAvailability() {
+ if (!isTransportSupported()) {
+ return NearbyAvailability.NOT_SUPPORTED.ordinal();
+ }
// Reported honestly rather than as a flat AVAILABLE. Nearby
// Connections needs Bluetooth and, depending on the level, nearby-WiFi
// or location; without them advertising and discovery fail on the
diff --git a/Ports/Android/src/com/codename1/impl/android/unifiedpush/CN1UnifiedPushService.java b/Ports/Android/src/com/codename1/impl/android/unifiedpush/CN1UnifiedPushService.java
new file mode 100644
index 00000000000..1104a54ca5e
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/unifiedpush/CN1UnifiedPushService.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.impl.android.unifiedpush;
+
+import com.codename1.impl.CodenameOneImplementation;
+import com.codename1.impl.android.AndroidImplementation;
+import com.codename1.impl.android.StubUtil;
+import com.codename1.io.Preferences;
+import com.codename1.push.PushCallback;
+import com.codename1.ui.Display;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.unifiedpush.android.connector.FailedReason;
+import org.unifiedpush.android.connector.PushService;
+import org.unifiedpush.android.connector.data.PublicKeySet;
+import org.unifiedpush.android.connector.data.PushEndpoint;
+import org.unifiedpush.android.connector.data.PushMessage;
+
+/// Receives the UnifiedPush endpoint and messages from the distributor.
+///
+/// Declared in the manifest by the builder with the connector's `PUSH_EVENT`
+/// action. See [UnifiedPushSupport] for why the endpoint is registered as a Web
+/// Push subscription.
+public class CN1UnifiedPushService extends PushService {
+ /// The prefix of a Web Push device key; `PushClient` reads the provider from it.
+ static final String WEB_KEY_PREFIX = "cn1-web-";
+
+ @Override
+ public void onNewEndpoint(PushEndpoint endpoint, String instance) {
+ PublicKeySet keys = endpoint.getPubKeySet();
+ if (keys == null) {
+ // Only a distributor older than UnifiedPush 2 answers without keys, and
+ // the push service encrypts every message: it could deliver nothing.
+ UnifiedPushSupport.fail("The UnifiedPush distributor does not support Web Push "
+ + "encryption; update it");
+ return;
+ }
+ final String deviceKey;
+ try {
+ deviceKey = deviceKey(endpoint.getUrl(), keys.getPubKey(), keys.getAuth());
+ } catch (JSONException err) {
+ UnifiedPushSupport.fail("UnifiedPush registration failed: " + err);
+ return;
+ }
+ if (!Display.isInitialized()) {
+ // A distributor can rotate the endpoint while the application is not
+ // running. Store it anyway, so push_key never names a dead endpoint;
+ // the push server learns it when the application next starts and
+ // registers, which the distributor answers with this endpoint.
+ AndroidImplementation.startContext(this);
+ try {
+ Preferences.set("push_key", deviceKey);
+ } finally {
+ AndroidImplementation.stopContext(this);
+ }
+ return;
+ }
+ Preferences.set("push_key", deviceKey);
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ PushCallback callback = CodenameOneImplementation.getPushCallback();
+ if (callback != null) {
+ callback.registeredForPush(deviceKey);
+ }
+ }
+ });
+ }
+
+ /// The Web Push device key for a subscription: `cn1-web-` and the base64url
+ /// form of the subscription JSON a browser's `PushSubscription.toJSON()` gives.
+ static String deviceKey(String url, String p256dh, String auth) throws JSONException {
+ JSONObject keys = new JSONObject();
+ keys.put("p256dh", p256dh);
+ keys.put("auth", auth);
+ JSONObject subscription = new JSONObject();
+ subscription.put("endpoint", url);
+ subscription.put("keys", keys);
+ byte[] json;
+ try {
+ json = subscription.toString().getBytes("UTF-8");
+ } catch (java.io.UnsupportedEncodingException impossible) {
+ throw new JSONException(impossible.toString());
+ }
+ return WEB_KEY_PREFIX + android.util.Base64.encodeToString(json,
+ android.util.Base64.URL_SAFE | android.util.Base64.NO_WRAP
+ | android.util.Base64.NO_PADDING);
+ }
+
+ @Override
+ public void onMessage(PushMessage message, String instance) {
+ if (!message.getDecrypted()) {
+ // The push service encrypts every message. One that arrives in clear
+ // was not sent by it, so it is dropped rather than shown.
+ com.codename1.io.Log.p("Dropped an unencrypted UnifiedPush message");
+ return;
+ }
+ String envelope;
+ try {
+ envelope = new String(message.getContent(), "UTF-8");
+ } catch (java.io.UnsupportedEncodingException impossible) {
+ return;
+ }
+ if (envelope.length() > 0) {
+ AndroidImplementation.handleV3Push(envelope, this, StubUtil.appIsRunning(),
+ StubUtil.getAppStubClass());
+ }
+ }
+
+ @Override
+ public void onRegistrationFailed(FailedReason reason, String instance) {
+ UnifiedPushSupport.fail("UnifiedPush registration failed: " + reason);
+ }
+
+ @Override
+ public void onUnregistered(String instance) {
+ boolean started = !Display.isInitialized();
+ if (started) {
+ AndroidImplementation.startContext(this);
+ }
+ try {
+ String key = Preferences.get("push_key", null);
+ if (key != null && key.startsWith(WEB_KEY_PREFIX)) {
+ Preferences.delete("push_key");
+ }
+ } finally {
+ if (started) {
+ AndroidImplementation.stopContext(this);
+ }
+ }
+ }
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/unifiedpush/UnifiedPushSupport.java b/Ports/Android/src/com/codename1/impl/android/unifiedpush/UnifiedPushSupport.java
new file mode 100644
index 00000000000..79a635bde7f
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/unifiedpush/UnifiedPushSupport.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.impl.android.unifiedpush;
+
+import android.app.Activity;
+import android.content.Context;
+import com.codename1.impl.CodenameOneImplementation;
+import com.codename1.push.PushCallback;
+import com.codename1.ui.Display;
+import kotlin.Unit;
+import kotlin.jvm.functions.Function1;
+import org.unifiedpush.android.connector.UnifiedPush;
+
+/// Registers the application with a UnifiedPush distributor.
+///
+/// UnifiedPush is the push transport of Android builds without Google Play
+/// services: a distributor app the user chose (ntfy, NextPush, Sunup and others)
+/// keeps the one connection to a push server, and hands this application an
+/// endpoint URL. Since version 2 of the specification that endpoint is a
+/// standard Web Push (RFC 8030) resource, with the message encrypted to keys the
+/// connector generates (RFC 8291) and the sender identified by VAPID (RFC 8292).
+///
+/// So the subscription is registered with the push service exactly as a
+/// browser's is -- device key `cn1-web-` followed by the base64url subscription
+/// JSON -- and the service delivers it with its Web Push sender and the
+/// application's VAPID credential. No Codename One server needs to know that the
+/// endpoint belongs to a phone. [CN1UnifiedPushService] receives the endpoint and
+/// the decrypted messages.
+///
+/// The builder compiles this package only into an application that uses
+/// `com.codename1.push.PushClient` and turned UnifiedPush on, together with the
+/// connector library; the port jar never compiles it.
+public final class UnifiedPushSupport {
+ /// The connector's default registration instance.
+ static final String INSTANCE = "default";
+
+ private UnifiedPushSupport() {
+ }
+
+ /// Whether a UnifiedPush distributor is installed on this device.
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ ///
+ /// #### Returns
+ ///
+ /// true when at least one distributor app is installed
+ public static boolean isDistributorInstalled(Context context) {
+ try {
+ return !UnifiedPush.getDistributors(context).isEmpty();
+ } catch (Throwable err) {
+ return false;
+ }
+ }
+
+ /// Registers with the distributor the user already linked, the system
+ /// default one, or one the user picks, in that order.
+ ///
+ /// The endpoint arrives later, in [CN1UnifiedPushService]. Every failure --
+ /// no distributor, the user declining to pick one, a VAPID key the connector
+ /// rejects -- reaches the push callback as a registration error.
+ ///
+ /// #### Parameters
+ ///
+ /// - `activity`: the running activity; picking a distributor shows one
+ /// - `vapidPublicKey`: the application's VAPID public key, base64url, or null
+ public static void register(final Activity activity, final String vapidPublicKey) {
+ activity.runOnUiThread(new Runnable() {
+ public void run() {
+ try {
+ UnifiedPush.tryUseCurrentOrDefaultDistributor(activity,
+ new Function1() {
+ public Unit invoke(Boolean linked) {
+ if (Boolean.TRUE.equals(linked)) {
+ registerWithDistributor(activity, vapidPublicKey);
+ } else if (isDistributorInstalled(activity)) {
+ pickDistributor(activity, vapidPublicKey);
+ } else {
+ fail("No push service is available: Google Play services is "
+ + "not installed and no UnifiedPush distributor "
+ + "(for example ntfy) is installed");
+ }
+ return Unit.INSTANCE;
+ }
+ });
+ } catch (Throwable err) {
+ fail("UnifiedPush registration failed: " + err);
+ }
+ }
+ });
+ }
+
+ private static void pickDistributor(final Activity activity, final String vapidPublicKey) {
+ UnifiedPush.tryPickDistributor(activity, new Function1() {
+ public Unit invoke(Boolean picked) {
+ if (Boolean.TRUE.equals(picked)) {
+ registerWithDistributor(activity, vapidPublicKey);
+ } else {
+ fail("No UnifiedPush distributor was chosen");
+ }
+ return Unit.INSTANCE;
+ }
+ });
+ }
+
+ private static void registerWithDistributor(Context context, String vapidPublicKey) {
+ try {
+ String label = context.getApplicationInfo().loadLabel(context.getPackageManager())
+ .toString();
+ String vapid = vapidPublicKey == null || vapidPublicKey.length() == 0
+ ? null : vapidPublicKey;
+ UnifiedPush.register(context, INSTANCE, label, vapid);
+ } catch (Throwable err) {
+ fail("UnifiedPush registration failed: " + err);
+ }
+ }
+
+ /// Removes the registration. The distributor confirms in
+ /// [CN1UnifiedPushService#onUnregistered(String)].
+ ///
+ /// #### Parameters
+ ///
+ /// - `context`: any context of the application
+ public static void unregister(Context context) {
+ try {
+ UnifiedPush.unregister(context, INSTANCE);
+ } catch (Throwable err) {
+ com.codename1.io.Log.e(err);
+ }
+ }
+
+ /// Reports a registration failure to the application's push callback.
+ ///
+ /// #### Parameters
+ ///
+ /// - `message`: what went wrong
+ static void fail(final String message) {
+ com.codename1.io.Log.p(message);
+ if (!Display.isInitialized()) {
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ PushCallback callback = CodenameOneImplementation.getPushCallback();
+ if (callback != null) {
+ callback.pushRegistrationError(message, 0);
+ }
+ }
+ });
+ }
+}
diff --git a/Ports/Android/src/com/codename1/location/AndroidLocationManager.java b/Ports/Android/src/com/codename1/location/AndroidLocationManager.java
index 7e8302b25e4..5f78aa6fc9c 100644
--- a/Ports/Android/src/com/codename1/location/AndroidLocationManager.java
+++ b/Ports/Android/src/com/codename1/location/AndroidLocationManager.java
@@ -22,7 +22,10 @@
*/
package com.codename1.location;
+import android.app.PendingIntent;
import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
import android.location.Criteria;
import android.location.GpsStatus;
import android.location.GnssStatus;
@@ -33,12 +36,14 @@
import android.os.Looper;
import android.os.SystemClock;
import com.codename1.impl.android.AndroidImplementation;
+import com.codename1.impl.android.AndroidNativeUtil;
import com.codename1.io.Log;
import static com.codename1.location.LocationManager.AVAILABLE;
import static com.codename1.location.LocationManager.OUT_OF_SERVICE;
import static com.codename1.location.LocationManager.TEMPORARILY_UNAVAILABLE;
import com.codename1.ui.Display;
import java.io.IOException;
+import java.util.List;
public class AndroidLocationManager extends com.codename1.location.LocationManager implements android.location.LocationListener {
private LocationManager locationManager;
@@ -226,7 +231,10 @@ private void startListenToGPS() {
public void run() {
locationManager.requestLocationUpdates(bestProvider, 0, 0, AndroidLocationManager.this);
if (android.os.Build.VERSION.SDK_INT >= 24) {
- locationManager.unregisterGnssStatusCallback(gnssStatusListener());
+ // Register on start and unregister on clear. The two were
+ // swapped, so the status callback was only ever registered
+ // after the listener had been cleared.
+ locationManager.registerGnssStatusCallback(gnssStatusListener());
} else {
locationManager.addGpsStatusListener(gpsStatusListener());
}
@@ -242,7 +250,7 @@ public void clearListener() {
public void run() {
locationManager.removeUpdates(AndroidLocationManager.this);
if (android.os.Build.VERSION.SDK_INT >= 24) {
- locationManager.registerGnssStatusCallback(gnssStatusListener());
+ locationManager.unregisterGnssStatusCallback(gnssStatusListener());
} else {
locationManager.removeGpsStatusListener(gpsStatusListener());
}
@@ -349,4 +357,179 @@ public boolean isGPSEnabled() {
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER);
}
+ // ------------------------------------------------------------------
+ // Background location and geofences without Google Play services.
+ //
+ // The Play services manager is preferred where the service exists. This is
+ // what a device without it gets -- GrapheneOS, LineageOS without GApps,
+ // Huawei -- and also what an application gets when the builder was told to
+ // leave the Play services location library out. Both are delivered to
+ // FrameworkLocationReceiver, which names no Google type.
+ // ------------------------------------------------------------------
+
+ /// PendingIntent.FLAG_MUTABLE, API 31; absent from the SDK this port compiles
+ /// against. The location service adds its result to the intent as an extra,
+ /// which an immutable PendingIntent would refuse.
+ private static final int FLAG_MUTABLE = 33554432;
+
+ /// LocationManager.FUSED_PROVIDER, API 31: the platform's own fused provider,
+ /// part of AOSP and so present without Play services.
+ private static final String FUSED_PROVIDER = "fused";
+
+ static final String ACTION_BACKGROUND = "com.codename1.location.FRAMEWORK_BACKGROUND_LOCATION";
+ static final String ACTION_GEOFENCE = "com.codename1.location.FRAMEWORK_GEOFENCE";
+ static final String EXTRA_LISTENER_CLASS = "cn1ListenerClass";
+ static final String GEOFENCE_SCHEME = "cn1-geofence";
+ private static final Uri BACKGROUND_DATA = Uri.parse("cn1-location:background");
+
+ @Override
+ public boolean isBackgroundLocationSupported() {
+ return true;
+ }
+
+ @Override
+ public boolean isGeofenceSupported() {
+ return true;
+ }
+
+ private static int mutableFlags(int flags) {
+ return android.os.Build.VERSION.SDK_INT >= 31 ? flags | FLAG_MUTABLE : flags;
+ }
+
+ private boolean checkBackgroundPermission() {
+ // ACCESS_BACKGROUND_LOCATION exists from API 29; below it the foreground
+ // grant covers the background too.
+ return android.os.Build.VERSION.SDK_INT < 29
+ || AndroidNativeUtil.checkForPermission("android.permission.ACCESS_BACKGROUND_LOCATION",
+ "This is required to get the location");
+ }
+
+ /// The provider background updates come from: the platform fused provider
+ /// where there is one, else network, else GPS. A de-Googled device often has
+ /// no network provider at all -- that was Play services' -- so GPS is a real
+ /// answer here, not only a last resort.
+ private String backgroundProvider() {
+ List providers = locationManager.getProviders(true);
+ if (providers.contains(FUSED_PROVIDER)) {
+ return FUSED_PROVIDER;
+ }
+ if (providers.contains(LocationManager.NETWORK_PROVIDER)) {
+ return LocationManager.NETWORK_PROVIDER;
+ }
+ if (providers.contains(LocationManager.GPS_PROVIDER)) {
+ return LocationManager.GPS_PROVIDER;
+ }
+ if (providers.contains(LocationManager.PASSIVE_PROVIDER)) {
+ return LocationManager.PASSIVE_PROVIDER;
+ }
+ return null;
+ }
+
+ private PendingIntent backgroundIntent(Class listenerClass, int flags) {
+ Intent intent = new Intent(context, FrameworkLocationReceiver.class);
+ intent.setAction(ACTION_BACKGROUND);
+ intent.setData(BACKGROUND_DATA);
+ if (listenerClass != null) {
+ intent.putExtra(EXTRA_LISTENER_CLASS, listenerClass.getName());
+ }
+ return PendingIntent.getBroadcast(context, 0, intent, mutableFlags(flags));
+ }
+
+ /// One PendingIntent per geofence id: the id is the data URI, which is what
+ /// makes two intents distinct, so removeGeoFencing(id) finds it again without
+ /// knowing the listener class, which travels as an extra.
+ private PendingIntent geofenceIntent(String id, Class listenerClass, int flags) {
+ Intent intent = new Intent(context, FrameworkLocationReceiver.class);
+ intent.setAction(ACTION_GEOFENCE);
+ intent.setData(Uri.fromParts(GEOFENCE_SCHEME, id, null));
+ if (listenerClass != null) {
+ intent.putExtra(EXTRA_LISTENER_CLASS, listenerClass.getName());
+ }
+ return PendingIntent.getBroadcast(context, 0, intent, mutableFlags(flags));
+ }
+
+ private static long longProperty(String name, long defaultValue) {
+ try {
+ return Long.parseLong(Display.getInstance().getProperty(name, String.valueOf(defaultValue)));
+ } catch (NumberFormatException err) {
+ return defaultValue;
+ }
+ }
+
+ @Override
+ protected void bindBackgroundListener() {
+ final Class listenerClass = getBackgroundLocationListener();
+ if (listenerClass == null || !checkBackgroundPermission()) {
+ return;
+ }
+ final String provider = backgroundProvider();
+ if (provider == null) {
+ Log.p("No location provider is enabled; background location updates were not started");
+ return;
+ }
+ // The same build hints the Play services manager reads.
+ final long interval = longProperty("android.backgroundLocation.interval", 10000);
+ final long displacement = longProperty("android.backgroundLocation.smallestDisplacement", 50);
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ public void run() {
+ try {
+ locationManager.requestLocationUpdates(provider, interval, displacement,
+ backgroundIntent(listenerClass, PendingIntent.FLAG_UPDATE_CURRENT));
+ } catch (SecurityException denied) {
+ Log.e(denied);
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void clearBackgroundListener() {
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ public void run() {
+ PendingIntent pending = backgroundIntent(null, PendingIntent.FLAG_NO_CREATE);
+ if (pending != null) {
+ locationManager.removeUpdates(pending);
+ pending.cancel();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void addGeoFencing(final Class geofenceListenerClass, final Geofence gf) {
+ if (!AndroidNativeUtil.checkForPermission("android.permission.ACCESS_FINE_LOCATION",
+ "This is required to get the location") || !checkBackgroundPermission()) {
+ Log.e(new RuntimeException("Permission denied for geofence"));
+ return;
+ }
+ // Same meaning as the Play services geofence: a duration in milliseconds,
+ // with zero or less for one that never expires (-1 to the platform).
+ final long expiration = gf.getExpiration() > 0 ? gf.getExpiration() : -1;
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ public void run() {
+ try {
+ locationManager.addProximityAlert(gf.getLoc().getLatitude(),
+ gf.getLoc().getLongitude(), gf.getRadius(), expiration,
+ geofenceIntent(gf.getId(), geofenceListenerClass,
+ PendingIntent.FLAG_UPDATE_CURRENT));
+ } catch (SecurityException denied) {
+ Log.e(denied);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void removeGeoFencing(final String id) {
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ public void run() {
+ PendingIntent pending = geofenceIntent(id, null, PendingIntent.FLAG_NO_CREATE);
+ if (pending != null) {
+ locationManager.removeProximityAlert(pending);
+ pending.cancel();
+ }
+ }
+ });
+ }
+
}
diff --git a/Ports/Android/src/com/codename1/location/AndroidLocationPlayServiceManager.java b/Ports/Android/src/com/codename1/location/AndroidLocationPlayServiceManager.java
index adc412d2106..689ec0b7300 100644
--- a/Ports/Android/src/com/codename1/location/AndroidLocationPlayServiceManager.java
+++ b/Ports/Android/src/com/codename1/location/AndroidLocationPlayServiceManager.java
@@ -40,6 +40,7 @@
import android.support.v4.app.ActivityCompat;
import com.codename1.impl.android.AndroidImplementation;
import com.codename1.impl.android.AndroidNativeUtil;
+import com.codename1.impl.android.GoogleServices;
import com.codename1.impl.android.LifecycleListener;
import com.codename1.impl.android.PlayServices;
import com.codename1.io.Log;
@@ -619,7 +620,12 @@ public void onCreate(Bundle savedInstanceState) {
@Override
public void onResume() {
- getmGoogleApiClient(); // This should initialize it if it isn't already.
+ // The builder registers this listener for every application that uses
+ // location, but the manager is only chosen when Play services is there.
+ // Connecting a client without the service fails on every resume.
+ if (GoogleServices.isPlayServicesAvailable(AndroidNativeUtil.getContext())) {
+ getmGoogleApiClient(); // This should initialize it if it isn't already.
+ }
}
@Override
diff --git a/Ports/Android/src/com/codename1/location/FrameworkLocationReceiver.java b/Ports/Android/src/com/codename1/location/FrameworkLocationReceiver.java
new file mode 100644
index 00000000000..a2309b2a2e6
--- /dev/null
+++ b/Ports/Android/src/com/codename1/location/FrameworkLocationReceiver.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.location;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import com.codename1.impl.android.AndroidImplementation;
+import com.codename1.ui.Display;
+
+/// Delivers background location updates and geofence transitions requested by
+/// [AndroidLocationManager] through the platform location service.
+///
+/// The counterpart of `BackgroundLocationBroadcastReceiver`, which reads Play
+/// services results and is compiled only into applications that bundle the Play
+/// services location library. This one names no Google type, so it ships in every
+/// application and works on a device without Play services.
+///
+/// The listener is named by class in the intent and constructed here, because the
+/// application may not be running: the location service starts the process for a
+/// background update or a geofence transition.
+public class FrameworkLocationReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) {
+ return;
+ }
+ String listenerClass = intent.getStringExtra(AndroidLocationManager.EXTRA_LISTENER_CLASS);
+ if (listenerClass == null) {
+ return;
+ }
+ String action = intent.getAction();
+ if (AndroidLocationManager.ACTION_BACKGROUND.equals(action)) {
+ Object value = intent.getExtras() == null ? null
+ : intent.getExtras().get(android.location.LocationManager.KEY_LOCATION_CHANGED);
+ if (value instanceof android.location.Location) {
+ deliverLocation(context, listenerClass,
+ AndroidLocationManager.convert((android.location.Location) value));
+ }
+ } else if (AndroidLocationManager.ACTION_GEOFENCE.equals(action)) {
+ Uri data = intent.getData();
+ if (data == null
+ || !intent.hasExtra(android.location.LocationManager.KEY_PROXIMITY_ENTERING)) {
+ return;
+ }
+ deliverTransition(context, listenerClass, data.getSchemeSpecificPart(),
+ intent.getBooleanExtra(android.location.LocationManager.KEY_PROXIMITY_ENTERING, false));
+ }
+ }
+
+ private static Object newListener(String className) {
+ try {
+ return Class.forName(className).newInstance();
+ } catch (Throwable err) {
+ android.util.Log.e("Codename One", "Cannot create location listener " + className, err);
+ return null;
+ }
+ }
+
+ // The listener runs synchronously in onReceive, as BackgroundLocationBroadcastReceiver
+ // runs it on the Play services path: the background listener contract is a short
+ // callback, and both paths keep the same threading so an application sees one.
+ private static void deliverLocation(Context context, String className, Location location) {
+ boolean stopWhenDone = startIfNeeded(context);
+ try {
+ Object listener = newListener(className);
+ if (listener instanceof LocationListener) {
+ ((LocationListener) listener).locationUpdated(location);
+ }
+ } catch (Throwable err) {
+ android.util.Log.e("Codename One", "background location error", err);
+ } finally {
+ if (stopWhenDone) {
+ AndroidImplementation.stopContext(context);
+ }
+ }
+ }
+
+ private static void deliverTransition(Context context, String className, String id,
+ boolean entering) {
+ boolean stopWhenDone = startIfNeeded(context);
+ try {
+ Object listener = newListener(className);
+ if (listener instanceof GeofenceListener) {
+ if (entering) {
+ ((GeofenceListener) listener).onEntered(id);
+ } else {
+ ((GeofenceListener) listener).onExit(id);
+ }
+ }
+ } catch (Throwable err) {
+ android.util.Log.e("Codename One", "geofence error", err);
+ } finally {
+ if (stopWhenDone) {
+ AndroidImplementation.stopContext(context);
+ }
+ }
+ }
+
+ private static boolean startIfNeeded(Context context) {
+ if (Display.isInitialized()) {
+ return false;
+ }
+ AndroidImplementation.startContext(context);
+ return true;
+ }
+}
diff --git a/Ports/Android/src/com/codename1/social/GoogleImpl.java b/Ports/Android/src/com/codename1/social/GoogleImpl.java
index 8a77057407c..47fb12a7655 100644
--- a/Ports/Android/src/com/codename1/social/GoogleImpl.java
+++ b/Ports/Android/src/com/codename1/social/GoogleImpl.java
@@ -83,7 +83,11 @@ public static void init() {
@Override
public boolean isNativeLoginSupported() {
- return true;
+ // Native sign-in is a Play services API. Without the service the client
+ // never connects and its resolution only prompts the user to install
+ // Google Play services, so Login falls back to the web OAuth flow instead.
+ return com.codename1.impl.android.GoogleServices.isPlayServicesAvailable(
+ AndroidNativeUtil.getContext());
}
@Override
@@ -386,7 +390,7 @@ public void onCreate(Bundle savedInstanceState) {
}
public void onResume() {
- if (wasConnected) {
+ if (wasConnected && isNativeLoginSupported()) {
wasConnected = false;
getClient(null);
}
diff --git a/docs/demos/common/src/main/snippets/developer-guide/android-without-google-play.properties b/docs/demos/common/src/main/snippets/developer-guide/android-without-google-play.properties
new file mode 100644
index 00000000000..433e6b48afd
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/android-without-google-play.properties
@@ -0,0 +1,5 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::android-without-google-play-properties-001[]
+codename1.arg.android.unifiedPush.vapidKey=BExampleKeyOf87Base64UrlCharacters...
+// end::android-without-google-play-properties-001[]
diff --git a/docs/developer-guide/Android-Without-Google-Play.asciidoc b/docs/developer-guide/Android-Without-Google-Play.asciidoc
new file mode 100644
index 00000000000..6cd1bbcfbcb
--- /dev/null
+++ b/docs/developer-guide/Android-Without-Google-Play.asciidoc
@@ -0,0 +1,98 @@
+[[android-without-google-play]]
+== Android without Google Play Services
+
+Many Android devices have no Google Play Services. GrapheneOS, CalyxOS, /e/OS
+and LineageOS without Google apps ship without it, and GrapheneOS installs it
+only as an ordinary app the user can remove. Huawei and Amazon devices have
+their own services instead. A Codename One application runs on these devices
+without a separate build: the same APK checks at run time whether Play
+services is present and uses a fallback, or reports a feature as unsupported,
+when it isn't.
+
+No build hint turns this on. The Android port never calls into Play services,
+Play Core or Firebase without asking first, and a CI check holds it to that.
+
+=== What changes without Play services
+
+[cols="1,2",options="header"]
+|===
+|Feature |Behavior without Google Play Services
+
+|User interface, storage, networking, SQLite, media
+|Unchanged. None of them use Play services.
+
+|Location
+|Uses Android's own location manager. Background location and geofences use
+the framework's pending-intent updates and proximity alerts instead of the
+fused provider. The listener API and the `android.backgroundLocation.*` build
+hints are the same. An application built without Play services location
+uses the same fallback on Google devices too, where background location and
+geofences used to be unsupported.
+
+|Maps
+|`NativeMap` falls back to the vector `MapView` instead of the native Google map.
+
+|Push notifications
+|`PushClient` delivers through UnifiedPush (see below) or Huawei Push Kit when
+the build includes them. Without any of them, `pushRegistrationError` reports
+that no push service is available rather than registration never answering.
+
+|Google sign-in
+|`isNativeLoginSupported()` answers `false`, so `GoogleConnect` signs in
+through the browser OAuth flow.
+
+|On-device AI (ML Kit)
+|Works. The bundled models run locally.
+
+|Device attestation (Play Integrity)
+|`isAttestationSupported()` answers `false`.
+
+|In-app review
+|`isSupported()` answers `false`. The review prompt belongs to the Play Store.
+
+|Nearby connections
+|The transport reports `NOT_SUPPORTED`.
+
+|Wear OS data layer
+|Unavailable.
+
+|In-app purchase
+|Google Play Billing needs the Play Store; purchases are unavailable on these
+devices.
+|===
+
+Test an application on such a device with the Android emulator's *AOSP*
+system images (`default` target), which have no Google apps.
+
+=== Push notifications through UnifiedPush
+
+https://unifiedpush.org[UnifiedPush] lets the user choose the app that holds
+the push connection: ntfy, NextPush, Sunup and others, called distributors. A
+UnifiedPush endpoint is a standard Web Push subscription, so the push service
+delivers to it with the *Web Push* credential the application already has. It
+needs no server change and no other credential.
+
+To enable it, copy the VAPID public key from the application's Web Push
+credential into a build hint:
+
+[source,properties]
+----
+include::../demos/common/src/main/snippets/developer-guide/android-without-google-play.properties[tag=android-without-google-play-properties-001,indent=0]
+----
+
+The key is 87 base64url characters. The build rejects anything else. With the
+hint set and `PushClient` in use, the application registers in this order:
+
+. Firebase Cloud Messaging, when Google Play Services is available and the
+ build includes `google-services.json`.
+. Huawei Push Kit, when HMS Core is installed and the build includes
+ `agconnect-services.json`.
+. UnifiedPush, otherwise. If the user has more than one distributor installed,
+ Android asks which one to use. With none installed, `pushRegistrationError`
+ explains that a push service is needed.
+
+The device key the application receives starts with `cn1-web-`, and the push
+service routes it as a Web Push target. `android.messagingService=unifiedpush`
+forces UnifiedPush even without the key, for distributors that don't require
+one. `android.unifiedPush.connectorVersion` overrides the version of the
+UnifiedPush connector library the build adds.
diff --git a/docs/developer-guide/Push-Notifications.asciidoc b/docs/developer-guide/Push-Notifications.asciidoc
index 7d61effa845..f1d315aaa2b 100644
--- a/docs/developer-guide/Push-Notifications.asciidoc
+++ b/docs/developer-guide/Push-Notifications.asciidoc
@@ -70,7 +70,7 @@ Credentials are encrypted in the BuildCloud secrets vault and are write-only. Th
==== Prepare a new client build
-Cloud builds detect `PushClient` usage and generate the typed native bootstrap automatically. On Android, include `google-services.json`, `agconnect-services.json`, or both. A build with both configurations uses FCM when Google Play Services are available on the device and falls back to Huawei Push Kit otherwise. No provider-selection build hint is required. The `android.messagingService=fcm` and `android.messagingService=huawei` hints remain available only when an application intentionally needs to force one provider.
+Cloud builds detect `PushClient` usage and generate the typed native bootstrap automatically. On Android, include `google-services.json`, `agconnect-services.json`, or both. A build with both configurations uses FCM when Google Play Services are available on the device and falls back to Huawei Push Kit otherwise. No provider-selection build hint is required. Devices without Google Play Services or HMS Core, such as GrapheneOS and LineageOS, can receive through UnifiedPush with the Web Push credential; see <>. The `android.messagingService=fcm` and `android.messagingService=huawei` hints remain available only when an application intentionally needs to force one provider.
iOS enables push support automatically when `PushClient` is referenced; set `ios.includePush=false` only when an installed custom-provider adapter intentionally owns registration.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index 21a24930daf..3bc0fa4bb19 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -254,6 +254,8 @@ include::On-Device-Debugging.asciidoc[]
include::On-Device-Debugging-Android.asciidoc[]
+include::Android-Without-Google-Play.asciidoc[]
+
= Build, sign and ship
include::Build-Hints.asciidoc[]
diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt
index 6cf628d4961..ea56f19762e 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -830,3 +830,4 @@ callee
signalling
coturn
unconfigured
+ntfy
diff --git a/maven/android/pom.xml b/maven/android/pom.xml
index f755ef9b979..7d94497ea0d 100644
--- a/maven/android/pom.xml
+++ b/maven/android/pom.xml
@@ -129,6 +129,12 @@
app that referenced com.codename1.webrtc; the
builder deletes the package for everyone else. -->
com/codename1/impl/android/webrtc/**
+
+ com/codename1/impl/android/unifiedpush/**