From 50f65e83caab4cfdfe95960ed5933160382597d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:36:28 +0200 Subject: [PATCH 1/5] Android: run without Google Play services; UnifiedPush delivery De-Googled Android (GrapheneOS, CalyxOS, /e/OS, LineageOS without GApps) is supported implicitly, with no build mode: - GoogleServices proxies every Play services / Play Core / Firebase call behind isPlayServicesAvailable or isPlayStoreInstalled. Location, Google sign-in, in-app review, Nearby, Wear OS and Play Integrity now fall back or report unsupported instead of hanging or prompting for Play services. - scripts/check-android-gms-isolation.py (own workflow) holds the port to that: only the allow-listed, guarded proxies may name a Google Play type. - Framework location fallback for background updates and geofences (FrameworkLocationReceiver); fixes the inverted GNSS register/unregister. - registerPush reports pushRegistrationError when no transport exists. - UnifiedPush transport for PushClient apps (android.unifiedPush.vapidKey or android.messagingService=unifiedpush): endpoints are Web Push subscriptions sent as cn1-web- keys, so the push server needs no change. The activity tries FCM, then HMS, then UnifiedPush. - Builder: for Play services and the Play Store, the startup FCM fetch guarded, hints declared in the catalog. - The JDK 21 Android leg runs the device suite on an AOSP image without Google apps. - Developer guide chapter with the feature matrix. Co-Authored-By: Claude Opus 5.5 --- .../workflows/check-android-gms-isolation.yml | 47 +++ .github/workflows/scripts-android.yml | 9 +- Ports/Android/build.xml | 2 +- .../impl/android/AndroidImplementation.java | 139 +++------ .../impl/android/AndroidWearableSupport.java | 6 + .../impl/android/AppReviewSupport.java | 5 +- .../impl/android/GoogleServices.java | 267 ++++++++++++++++++ .../nearby/AndroidNearbyTransport.java | 8 +- .../unifiedpush/CN1UnifiedPushService.java | 137 +++++++++ .../unifiedpush/UnifiedPushSupport.java | 172 +++++++++++ .../location/AndroidLocationManager.java | 187 +++++++++++- .../AndroidLocationPlayServiceManager.java | 8 +- .../location/FrameworkLocationReceiver.java | 125 ++++++++ .../src/com/codename1/social/GoogleImpl.java | 8 +- .../Android-Without-Google-Play.asciidoc | 96 +++++++ .../Push-Notifications.asciidoc | 2 +- docs/developer-guide/developer-guide.asciidoc | 2 + maven/android/pom.xml | 6 + .../builders/AndroidGradleBuilder.java | 237 +++++++++++++--- .../AndroidGradleBuilderVersionTest.java | 70 +++++ .../build/shared/BuildHintsAndroid.java | 25 +- scripts/android-gms-isolation-allowlist.txt | 18 ++ scripts/check-android-gms-isolation.py | 158 +++++++++++ 23 files changed, 1587 insertions(+), 147 deletions(-) create mode 100644 .github/workflows/check-android-gms-isolation.yml create mode 100644 Ports/Android/src/com/codename1/impl/android/GoogleServices.java create mode 100644 Ports/Android/src/com/codename1/impl/android/unifiedpush/CN1UnifiedPushService.java create mode 100644 Ports/Android/src/com/codename1/impl/android/unifiedpush/UnifiedPushSupport.java create mode 100644 Ports/Android/src/com/codename1/location/FrameworkLocationReceiver.java create mode 100644 docs/developer-guide/Android-Without-Google-Play.asciidoc create mode 100644 scripts/android-gms-isolation-allowlist.txt create mode 100755 scripts/check-android-gms-isolation.py 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..76db12ddaf3 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -161,9 +161,16 @@ 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). + # Screenshots on these legs are uploaded, not compared, so the image + # changes no baseline; a test problem still fails the leg. - name: "JDK 21" id: jdk21 java_version: '21' + emulator_target: default permissions: contents: read pull-requests: write @@ -320,7 +327,7 @@ 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 }}" # After the emulator, deliberately: the instrumentation suite runs against 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/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..66565e26fe9 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/GoogleServices.java @@ -0,0 +1,267 @@ +/* + * 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 + /// old for the bundled client, or a service still being set up, answers false + /// rather than handing the caller a client that will never connect. + /// + /// #### 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); + // ConnectionResult.SUCCESS is 0. Anything else -- SERVICE_MISSING, + // SERVICE_DISABLED, SERVICE_VERSION_UPDATE_REQUIRED, SERVICE_INVALID -- + // is a client that would sit waiting or ask the user to fix the device. + return result instanceof Integer && ((Integer) result).intValue() == 0; + } 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..34f26c07a16 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/unifiedpush/CN1UnifiedPushService.java @@ -0,0 +1,137 @@ +/* + * 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. Nothing can be stored or reported without the Codename One + // context; the application registers again when it next starts, and the + // distributor answers that with the current endpoint. + 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) { + if (Display.isInitialized()) { + String key = Preferences.get("push_key", null); + if (key != null && key.startsWith(WEB_KEY_PREFIX)) { + Preferences.delete("push_key"); + } + } + } +} 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..c23cb6dadea --- /dev/null +++ b/Ports/Android/src/com/codename1/location/FrameworkLocationReceiver.java @@ -0,0 +1,125 @@ +/* + * 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; + } + } + + 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/developer-guide/Android-Without-Google-Play.asciidoc b/docs/developer-guide/Android-Without-Google-Play.asciidoc new file mode 100644 index 00000000000..dec2918e82a --- /dev/null +++ b/docs/developer-guide/Android-Without-Google-Play.asciidoc @@ -0,0 +1,96 @@ +[[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. + +|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] +---- +codename1.arg.android.unifiedPush.vapidKey=BExampleKeyOf87Base64UrlCharacters... +---- + +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..74170ced245 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/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/**