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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/check-android-gms-isolation.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 14 additions & 2 deletions .github/workflows/scripts-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Ports/Android/build.xml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
a package to one file is not adding it. -->
<javac destdir="build/tmp"
classpath="${javac.classpath}:build/tmp"
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">
<src path="${src.dir}" />
</javac>
<jar jarfile="${dist.jar}" basedir="build/tmp">
Expand Down
2 changes: 1 addition & 1 deletion Ports/Android/nbproject/project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<PackageInfo> 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 <queries> 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
Expand All @@ -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 = "";
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> requestIntegrityToken(final String nonce) {
final AsyncResource<String> result = new AsyncResource<String>();
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
Loading
Loading